diff --git a/compiler/cook.go b/compiler/cook.go index 2d0b8cb79a..107e86ed13 100644 --- a/compiler/cook.go +++ b/compiler/cook.go @@ -60,6 +60,9 @@ func (c *Compiler) Cook(u *ir.Unit, root string, wire []byte, opts CookOptions) if err := refuseToolMaps(u); err != nil { return nil, CookReport{}, TableReport{}, err } + if err := refuseToolLists(u); err != nil { + return nil, CookReport{}, TableReport{}, err + } var rep CookReport m := tabletext.NewModel(u) st := m.Lookup(root) @@ -117,6 +120,9 @@ func (c *Compiler) CookCheck(u *ir.Unit, root string, file []byte) (CookReport, if err := refuseToolMaps(u); err != nil { return CookReport{}, err } + if err := refuseToolLists(u); err != nil { + return CookReport{}, err + } m := tabletext.NewModel(u) res, err := tablecook.Check(m, file) if err != nil { @@ -150,6 +156,9 @@ func (c *Compiler) Uncook(u *ir.Unit, root string, file []byte) ([]byte, error) if err := refuseToolMaps(u); err != nil { return nil, err } + if err := refuseToolLists(u); err != nil { + return nil, err + } m := tabletext.NewModel(u) st := m.Lookup(root) if st == nil || !st.IsTable { diff --git a/compiler/tableslists.go b/compiler/tableslists.go new file mode 100644 index 0000000000..fc9b0eacb2 --- /dev/null +++ b/compiler/tableslists.go @@ -0,0 +1,49 @@ +// The UNBOUNDED ARRAY cross-target refusal (docs/SPEC-TABLES.md §2.9, §11): +// its own file, per the registry split — a construct's refusal adds a file +// beside builtin.go rather than growing it. Every target's Generate calls +// [refuseLists], because no code generator carries the construct yet; the +// carrier registry the map's file has lands here with the first carrier. +package compiler + +import ( + "fmt" + + "github.com/mas-bandwidth/schema/v2/ir" +) + +// refuseLists is the named refusal every target gives a unit whose table +// closure declares a `[]T` (docs/SPEC-TABLES.md §2.9, §11, §15). +// +// An unbounded array is a VARIABLE-CLASS construct, and the variable class is +// the C++ reference's alone — the arena, the region, the node extent and the +// walks a list's elements ride in are all the reference's. The LANGUAGE takes +// the spelling and the tool's WIRE and TEXT halves carry it, so `pack` and +// `unpack` read and write one, and a generator that emitted a codec for it +// would emit one that never met an element array. So every target refuses by +// name until the reference lands the codec. +func refuseLists(u *ir.Unit, target string) error { + fields := ir.ListFields(u) + if len(fields) == 0 { + return nil + } + return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — no code generator carries `[]T` yet, %s included: the language takes the spelling and the tool's `pack` and `unpack` read and write it, and the C++ reference lands the codec first (docs/SPEC-TABLES.md §2.9, §11, §15). Declare the array at a bound, [..N]T, which is the same bytes, and remove the bound when the reference carries it", + englishList(fields), target) +} + +// refuseToolLists is the TOOL's COOK refusal (docs/SPEC-TABLES.md §2.9, §15), +// the map's own, one construct over: a unit whose table closure declares a +// `[]T` is refused by name at the tool's COOK surfaces, because +// internal/tablecook does not lay out the element arrays yet. +// +// It is here, at the surface, rather than in the engine, and it is NAMED +// rather than left to the layout. Without it the engine lays out a region +// short of the element arrays and a reader meets a slot pointing past its +// holder's extent, which is a corrupt file with nothing saying who wrote it. +func refuseToolLists(u *ir.Unit) error { + fields := ir.ListFields(u) + if len(fields) == 0 { + return nil + } + return fmt.Errorf("unit declares an unbounded array in a table closure (%s) — the tool's WIRE and TEXT halves carry the construct, and its COOK half does not, so this command would lay out a region short of the element arrays rather than refusing; the C++ reference lands the cook (--lang cpp) (docs/SPEC-TABLES.md §2.9, §15)", + englishList(fields)) +} diff --git a/compiler/tableslists_test.go b/compiler/tableslists_test.go new file mode 100644 index 0000000000..9abd614cb6 --- /dev/null +++ b/compiler/tableslists_test.go @@ -0,0 +1,94 @@ +package compiler + +// The UNBOUNDED ARRAY's cross-target refusals (docs/SPEC-TABLES.md §2.9, §11, +// §15): no code generator carries the construct yet, so every one of them +// refuses a unit that declares one BY NAME, and none of them refuses a +// list-free unit for it. + +import ( + "strings" + "testing" + + "github.com/mas-bandwidth/schema/v2/ir" +) + +const listSrc = `package fixture + +table Placement +{ + x float32 +} + +table Save +{ + placements []Placement + scores []int32 +} +` + +// TestEveryTargetRefusesAList: the refusal is a REFUSAL and not a silent +// emission of an array whose elements no backend laid out. +func TestEveryTargetRefusesAList(t *testing.T) { + u := unitFromSource(t, listSrc) + c := New() + for _, target := range c.Targets() { + _, err := c.Generate(u, target, Options{}) + if err == nil { + t.Errorf("--lang %s emitted for a unit declaring an unbounded array", target) + continue + } + for _, want := range []string{"unbounded array", "Save.placements", "Save.scores", "[..N]T"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("--lang %s: the refusal does not name %q: %v", target, want, err) + } + } + } +} + +// TestNoTargetRefusesAListFreeUnit: the refusal is the CONSTRUCT's and not a +// tax on every unit. +func TestNoTargetRefusesAListFreeUnit(t *testing.T) { + u := unitFromSource(t, mapSrc) + if got := ir.ListFields(u); len(got) != 0 { + t.Fatalf("ListFields = %v for a list-free unit", got) + } + c := New() + for _, target := range c.Targets() { + if _, err := c.Generate(u, target, Options{}); err != nil && strings.Contains(err.Error(), "unbounded array") { + t.Errorf("--lang %s refused a list-free unit over unbounded arrays: %v", target, err) + } + } +} + +// TestListFieldsNamesWhatAnAuthorWrote: the refusal names `Table.field`, +// sorted, so a reader goes to a declaration and not to a generated name. +func TestListFieldsNamesWhatAnAuthorWrote(t *testing.T) { + got := ir.ListFields(unitFromSource(t, listSrc)) + want := []string{"Save.placements", "Save.scores"} + if len(got) != len(want) { + t.Fatalf("ListFields = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("ListFields = %v, want %v", got, want) + } + } +} + +// TestTheToolsCookRefusesAList: the tool's WIRE and TEXT halves carry the +// construct and its COOK half does not, so the cook surfaces refuse by name +// rather than laying out a region short of the element arrays. +func TestTheToolsCookRefusesAList(t *testing.T) { + err := refuseToolLists(unitFromSource(t, listSrc)) + if err == nil { + t.Fatal("the tool's cook accepted a unit declaring an unbounded array") + } + for _, want := range []string{"Save.placements", "WIRE and TEXT halves carry", "--lang cpp"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the cook refusal does not name %q: %v", want, err) + } + } + if err := refuseToolLists(unitFromSource(t, mapSrc)); err != nil { + t.Fatalf("the tool refused a list-free unit: %v", err) + } +} diff --git a/compiler/target_c.go b/compiler/target_c.go index be8ffefc0c..3e400aec8f 100644 --- a/compiler/target_c.go +++ b/compiler/target_c.go @@ -28,6 +28,9 @@ func (cTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "c"); err != nil { return nil, err } + if err := refuseLists(u, "c"); err != nil { + return nil, err + } files, err := cgen.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_cpp.go b/compiler/target_cpp.go index 9f6b60aef8..87625b4dd8 100644 --- a/compiler/target_cpp.go +++ b/compiler/target_cpp.go @@ -22,6 +22,13 @@ func (cppTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refusePacketVoidArms(u, "cpp"); err != nil { return nil, err } + // the UNBOUNDED ARRAY is OWED in this target (docs/SPEC-TABLES.md §2.9): + // the reference lands the codec first and registers through + // registers a carrier when it does. Until then it refuses one by name + // rather than emitting an array whose elements it never laid out. + if err := refuseLists(u, "cpp"); err != nil { + return nil, err + } files, err := cpp.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_cs.go b/compiler/target_cs.go index 89534c0b86..d97b66928d 100644 --- a/compiler/target_cs.go +++ b/compiler/target_cs.go @@ -25,6 +25,9 @@ func (csTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "cs"); err != nil { return nil, err } + if err := refuseLists(u, "cs"); err != nil { + return nil, err + } files, err := csharp.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_dart.go b/compiler/target_dart.go index 6d807f5127..86e10d92ca 100644 --- a/compiler/target_dart.go +++ b/compiler/target_dart.go @@ -25,6 +25,9 @@ func (dartTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "dart"); err != nil { return nil, err } + if err := refuseLists(u, "dart"); err != nil { + return nil, err + } files, err := dart.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_elixir.go b/compiler/target_elixir.go index 98e5671696..3a9e536c37 100644 --- a/compiler/target_elixir.go +++ b/compiler/target_elixir.go @@ -25,6 +25,9 @@ func (elixirTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "elixir"); err != nil { return nil, err } + if err := refuseLists(u, "elixir"); err != nil { + return nil, err + } files, err := elixir.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_go.go b/compiler/target_go.go index 618b6cc395..6a1930e116 100644 --- a/compiler/target_go.go +++ b/compiler/target_go.go @@ -25,6 +25,9 @@ func (goTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "go"); err != nil { return nil, err } + if err := refuseLists(u, "go"); err != nil { + return nil, err + } files, err := golang.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_java.go b/compiler/target_java.go index 77ecad42f9..2678a8266e 100644 --- a/compiler/target_java.go +++ b/compiler/target_java.go @@ -25,6 +25,9 @@ func (javaTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "java"); err != nil { return nil, err } + if err := refuseLists(u, "java"); err != nil { + return nil, err + } files, err := java.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_javascript.go b/compiler/target_javascript.go index 58ed16a37e..001dc79dbe 100644 --- a/compiler/target_javascript.go +++ b/compiler/target_javascript.go @@ -27,6 +27,9 @@ func (jsTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "js"); err != nil { return nil, err } + if err := refuseLists(u, "js"); err != nil { + return nil, err + } files, err := js.Generate(u) if err != nil { return nil, err diff --git a/compiler/target_rust.go b/compiler/target_rust.go index 7bbc5981a3..41742a75be 100644 --- a/compiler/target_rust.go +++ b/compiler/target_rust.go @@ -25,6 +25,9 @@ func (rustTarget) Generate(u *ir.Unit, _ Options) (map[string][]byte, error) { if err := refuseMaps(u, "rust"); err != nil { return nil, err } + if err := refuseLists(u, "rust"); err != nil { + return nil, err + } files, err := rust.Generate(u) if err != nil { return nil, err diff --git a/docs/COMPARISON-TABLES.md b/docs/COMPARISON-TABLES.md index 9cf10a5c28..cdc500742b 100644 --- a/docs/COMPARISON-TABLES.md +++ b/docs/COMPARISON-TABLES.md @@ -214,7 +214,7 @@ the source list at the end. | Optional fields | `?T` on a nested table, a type, an enum, a flags mask, a scalar and a bounded array: the value plus a `_present` bool, fixed size, no allocation (§2.3); on a string, on `bytes` and on a value whose closure is variable, a named follow-on (§15) | optional scalars via `= null`; references null when absent (FB-schema) | explicit presence: proto2 all, proto3 `optional`, editions explicit by default (PB-presence) | | Defaults | `= v` on scalars; part of the wire contract because a default is elided; changing one is a silent edit the baseline refuses (§4, §4.1, §18) | scalar defaults; "don't change existing default values" (FB-evolution) | proto2 `[default]`; proto3 zero, not serialized; editions serialize set defaults (PB-presence) | | Union | `union` with implicit `None`; arms by name hash, so add, remove, reorder freely; an arm IS a field line, so its type is any type a field's is — a `table` inside a table closure included — and an arm may carry no payload at all (§2.6, §5) | `union` of tables; structs and strings experimental; vectors of unions C++ only (FB-schema) | `oneof`; `Any` for open typing (PB-proto3) | -| Arrays | `[N]T`, `[..N]T`, `[A..B]T` on both wires, and `[]T` unbounded in a table body, whose count the data decides (§2.9, specified and emitted nowhere yet); the bound is not wire identity, so `[]T` and `[..N]T` are the same bytes (§2, §4) | `[T]` unbounded; `[T:N]` in structs only (FB-schema) | `repeated`, unbounded, packed scalars (PB-proto3) | +| Arrays | `[N]T`, `[..N]T`, `[A..B]T` on both wires, and `[]T` unbounded in a table body, whose count the data decides (§2.9, taken by the front end and by the tool, emitted by no backend yet); the bound is not wire identity, so `[]T` and `[..N]T` are the same bytes (§2, §4) | `[T]` unbounded; `[T:N]` in structs only (FB-schema) | `repeated`, unbounded, packed scalars (PB-proto3) | | Enum-keyed arrays | `[E]T`: one slot per variant, no `None` slot, bad keys refused in every build, slots ride by name (§2.4, §3.2) | — | — | | Maps | `map[K]V` in a table body, a lookup over entries the wire carries as a sorted array of one generated `{ key, value }` table; keys are `string(N)` and the integer kinds, every other key refused by name; makes the holder variable (§2.8), in the reference and the tool | sorted vector of tables with `key` plus `LookupByKey` (FB-cpp) | `map`, integral or string keys, order undefined (PB-proto3) | | Sorted lookup in a buffer | the map's `Find`: a binary search in place over the sorted entry array, the same call in a locked region, a loaded one and an opened cook, plus an optional index the caller builds at load and never stores (§2.8) | `key`, `CreateVectorOfSortedTables`, `LookupByKey` (FB-cpp) | — | @@ -268,7 +268,7 @@ the source list at the end. | Union evolution | arms by name; add anywhere, remove, reorder (§2.6, §5) | append or explicit discriminant (FB-evolution) | adding is fine; moving an existing field into a oneof is unsafe (PB-editions) | | Flags evolution | append only, retire in place; the baseline refuses the rest (§4.1) | explicit values, any order (FB-schema) | — | | Array bound change | prefix kept, `clamped` counted; a short array fills with defaults (§4) | unbounded | unbounded | -| Unbounded arrays | `[]T` and `[]*T` in a TABLE body, whose count the data decides (§2.9, specified and emitted nowhere yet); the same bytes as `[..N]T`, so the bound is a declaration-side fact and moving between them is silent or a clamp. Refused in a `type` body by name, which is what keeps the packet wire bounded | unbounded vectors | unbounded repeated fields | +| Unbounded arrays | `[]T` and `[]*T` in a TABLE body, whose count the data decides (§2.9, taken by the front end and by the tool, emitted by no backend yet); the same bytes as `[..N]T`, so the bound is a declaration-side fact and moving between them is silent or a clamp. Refused in a `type` body by name, which is what keeps the packet wire bounded | unbounded vectors | unbounded repeated fields | | `T` to `?T` to `*T` | `T` and `?T` are byte-identical for non-default content; to or from `*T` is a counted mismatch (§2.3, §4) | changes default semantics; required/optional changes break (FB-schema) | presence changes round-trip of defaults (PB-presence) | | Unknown fields on read | skipped by length, counted (§3, §4) | ignored (FB-evolution) | retained in the unknown set (PB-proto3) | | Unknown fields on rewrite | dropped and counted, by design; the writer has a schema | the buffer keeps them if forwarded whole (FB-evolution) | preserved and re-serialized (PB-proto3) | diff --git a/docs/SPEC-TABLES.md b/docs/SPEC-TABLES.md index b893c16032..8254a1b4ae 100644 --- a/docs/SPEC-TABLES.md +++ b/docs/SPEC-TABLES.md @@ -2755,11 +2755,13 @@ each: | a fixed-table user pays nothing | a list-free unit carries no list machinery, held by the zero-cost gate's header scan (§2.2) | every runtime carries the repeated codec | every runtime carries the vector | **BACKEND STATUS: OWED, not emitted.** This section is specified ahead of its -implementation, on the same terms §3.3 and §6.6 take: no backend carries an -unbounded array today, the front end refuses the spelling, and the corpus holds -no `tables/lists`. The C++ REFERENCE and the TOOL land it first and every other -backend refuses a unit that declares one, by name (§11), with the ports a named -follow-on (§15). The corpus the implementation owes is `tables/lists`: +implementation, on the same terms §3.3 and §6.6 take: **the FRONT END takes the +spelling and holds every refusal above, and the TOOL's WIRE and TEXT halves +carry the construct**, so `pack` and `unpack` read and write a `[]T` and the +projections render it. **No CODE GENERATOR carries it**, every one of them +refuses a unit that declares one by name (§11), and the corpus holds no +`tables/lists`. The C++ REFERENCE lands the codec next and every other backend +keeps refusing, with the ports a named follow-on (§15). The corpus the implementation owes is `tables/lists`: `list_empty` (an empty list beside a full one), `list_scalars`, `list_tables`, `list_shared` (two slots naming one node beside a null slot), `list_before_pointer` (the walk-order control above), `list_erased` (an erase diff --git a/docs/USAGE.md b/docs/USAGE.md index 5a37c8eb71..127c6b011c 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -2169,9 +2169,10 @@ its quoted decimal spelling, entries in ascending key order. ### Unbounded arrays: `placements []Placement` -*Specified, not yet emitted. The front end refuses the `[]T` spelling at -parse, no backend carries the construct, and the corpus holds no -`tables/lists` (SPEC-TABLES.md §2.9).* +*Partly landed. The front end takes the `[]T` spelling and holds every +refusal below, and `pack` and `unpack` read and write one. No backend carries +the construct — every one of them refuses a unit that declares one, by name — +and the corpus holds no `tables/lists` (SPEC-TABLES.md §2.9).* **An unbounded array is a counted array whose count the DATA decides.** It is the map with the key and the sort taken out: the same kind `14` body a diff --git a/internal/ast/ast.go b/internal/ast/ast.go index 7515096e38..a2037e16ec 100644 --- a/internal/ast/ast.go +++ b/internal/ast/ast.go @@ -153,6 +153,10 @@ const ( ArrayFixed ArrayKind = iota // [N]T ArrayUpTo // [..N]T — sugar for [0..N] (SPEC §4.3) ArrayRange // [Min..N]T + // ArrayList is `[]T` — an UNBOUNDED ARRAY, a counted array whose count + // the data decides (docs/SPEC-TABLES.md §2.9). The bracket carries no + // expression, so Lo and Hi are both nil. + ArrayList ) type ArrayBound struct { diff --git a/internal/baseline/baseline.go b/internal/baseline/baseline.go index a64efd39de..91f1b7f087 100644 --- a/internal/baseline/baseline.go +++ b/internal/baseline/baseline.go @@ -350,6 +350,15 @@ func renderField(f *ir.Field) Field { case f.Array == ir.ArrayCounted: add("array", "bounded") add("bound", strconv.FormatInt(f.ArrayBound, 10)) + case f.Array == ir.ArrayList: + // AN UNBOUNDED ARRAY RENDERS AS `array=unbounded` WITH NO `bound=` + // (docs/SPEC-TABLES.md §2.9): the count is the data's, and the only + // ceiling is the int32 extent cap every array shares (§2.2). The + // missing token IS the capacity fact — a `bound=` appearing on an + // `array=` move is a capacity SHRINK and warns, and one vanishing is + // a capacity GROWTH and passes (§18.2) — so the token's absence + // carries the verdict the token itself would have carried. + add("array", "unbounded") } if f.Type.Kind == ir.TFixed { // a fixed field's SCALE. The kind fixes the width and the signedness; diff --git a/internal/baseline/tablelist_test.go b/internal/baseline/tablelist_test.go new file mode 100644 index 0000000000..100742c0b6 --- /dev/null +++ b/internal/baseline/tablelist_test.go @@ -0,0 +1,105 @@ +package baseline_test + +// The UNBOUNDED ARRAY's half of the baseline (docs/SPEC-TABLES.md §2.9, +// §18.1, §18.2): the field renders `array=unbounded` with NO `bound=`, and +// the missing token IS the capacity fact — a `bound=` APPEARING on an +// `array=` move is a capacity SHRINK and warns, one VANISHING is a capacity +// GROWTH and passes. +// +// Every case carries this file's two controls: the DISCRIMINATION control +// (the same field edited in the direction the wire absorbs) and the +// ATTRIBUTION control (the same edit re-judged with the one policy row +// removed, which must go quiet). + +import ( + "strings" + "testing" + + "github.com/mas-bandwidth/schema/v2/internal/baseline" +) + +const listFixtureSrc = `package fixture + +table Placement +{ + x float32 +} + +table Save +{ + placements []Placement + scores []int32 +} +` + +func listDiff(t *testing.T, base, live string, policy map[string]baseline.TokenRule) []baseline.Finding { + t.Helper() + return baseline.Diff(committed(t, base), baseline.Render(unit(t, live)), policy) +} + +// TestUnboundedRendersWithNoBound: `array=unbounded` and no `bound=` at all, +// which is what leaves the capacity verdict to the token's own presence. +func TestUnboundedRendersWithNoBound(t *testing.T) { + text := baseline.Render(unit(t, listFixtureSrc)).Text() + for _, want := range []string{ + "kind=14 elem=13 type=Placement array=unbounded\n", + "kind=14 elem=4 array=unbounded\n", + } { + if !strings.Contains(text, want) { + t.Errorf("the list field's line does not read %q:\n%s", want, text) + } + } + if strings.Contains(text, "array=unbounded bound=") { + t.Errorf("an unbounded array carries no bound=:\n%s", text) + } +} + +// TestAddingABoundWarns: the direction that ADDS a bound gains the clamp, so +// it warns as any capacity shrunk does (§2.9, §18.2). +func TestAddingABoundWarns(t *testing.T) { + bounded := editOf(t, listFixtureSrc, "scores []int32", "scores [..4]int32") + fs := listDiff(t, listFixtureSrc, bounded, baseline.DefaultTokenPolicy) + if !find(fs, baseline.Warn, "Save.scores", "bound 4 added") { + t.Errorf("an unbounded array given a bound does not warn: %s", summary(fs)) + } + // ATTRIBUTION: drop the bound row and the edit goes quiet. The `array=` + // row is a SHAPE row and a bounded/unbounded move is not a map move, so + // it says nothing on its own — which is what leaves the whole verdict to + // the bound's presence. + if fs := listDiff(t, listFixtureSrc, bounded, without("bound")); len(fs) != 0 { + t.Errorf("with the bound row dropped the edit still reports: %s", summary(fs)) + } +} + +// TestRemovingABoundPasses: the direction that REMOVES one is the largest +// growth there is — no stored count can fail a bound that is gone — so it +// passes in silence (§2.9, §18.2). +func TestRemovingABoundPasses(t *testing.T) { + bounded := editOf(t, listFixtureSrc, "scores []int32", "scores [..4]int32") + if fs := listDiff(t, bounded, listFixtureSrc, baseline.DefaultTokenPolicy); len(fs) != 0 { + t.Errorf("a bound REMOVED for []T is a capacity grown and passes: %s", summary(fs)) + } + // DISCRIMINATION: the same field, the same direction of edit, at a + // capacity the baseline does judge — a bound SHRUNK still warns, so the + // silence above is the missing token and not a policy that stopped + // looking at this field. + shrunk := editOf(t, bounded, "scores [..4]int32", "scores [..2]int32") + if fs := listDiff(t, bounded, shrunk, baseline.DefaultTokenPolicy); !find(fs, baseline.Warn, "Save.scores", "bound 4 -> 2") { + t.Errorf("a bound shrunk between two bounded spellings still warns: %s", summary(fs)) + } +} + +// TestTheElementIsJudgedUnmoved: `elem=`, `type=` and `kind=` are unmoved by +// the array's class, so an element retyped or moved to or from `[]*T` refuses +// under the shape that was there and under the shape that replaces it alike +// (§2.9, §18.1). +func TestTheElementIsJudgedUnmoved(t *testing.T) { + retyped := editOf(t, listFixtureSrc, "scores []int32", "scores []float32") + if fs := listDiff(t, listFixtureSrc, retyped, baseline.DefaultTokenPolicy); len(fs) == 0 { + t.Errorf("an unbounded array's element retyped is not reported: %s", summary(fs)) + } + pointered := editOf(t, listFixtureSrc, "placements []Placement", "placements []*Placement") + if fs := listDiff(t, listFixtureSrc, pointered, baseline.DefaultTokenPolicy); len(fs) == 0 { + t.Errorf("an unbounded array's element moved to []*T is not reported: %s", summary(fs)) + } +} diff --git a/internal/check/check.go b/internal/check/check.go index 7f118cd142..1955409596 100644 --- a/internal/check/check.go +++ b/internal/check/check.go @@ -1240,67 +1240,79 @@ func (c *checker) resolveField(owner string, f *ast.Field, inTable bool) *ir.Fie c.errf(f.Pos, "an array of %s is not supported in v1 — wrap the element in a type", scalarName(out.Type.Kind)) return nil } - // an ENUM-KEYED array: the bound NAMES a declared enum rather than - // evaluating to a count — `ships [ShipType]ShipConfig`, one slot per - // variant, indexed by the variant (docs/SPEC-TABLES.md §2.4) - if !c.resolveKeyBound(f, out) { - return nil - } - switch { - case out.KeyEnum != "": - if out.Type.Pointer { - // a KEYED array of pointers is a named follow-on (docs/SPEC-TABLES.md - // §2.4, §15); the bounded spellings `[N]*T` and `[..N]*T` serve (§2.1) - c.errf(f.Type.Pos, "field %s: [%s]*%s is a named follow-on — an enum-keyed array of pointers; declare [..N]*%s or [N]*%s, or key an array of tables by value (docs/SPEC-TABLES.md §2.4, §15)", - f.Name, out.KeyEnum, f.Type.Name, f.Type.Name, f.Type.Name) - return nil - } - if _, isUnion := out.Type.Ref.(*ir.Union); isUnion { - // a KEYED array of unions is a named follow-on (docs/SPEC-TABLES.md - // §2.6, §15); the bounded spellings `[N]U` and `[..N]U` serve - c.errf(f.Type.Pos, "field %s: [%s]%s is a named follow-on — an enum-keyed array of unions; declare [..N]%s or [N]%s (docs/SPEC-TABLES.md §2.6, §15)", - f.Name, out.KeyEnum, f.Type.Name, f.Type.Name, f.Type.Name) - return nil - } - // ONE SLOT PER NAMED VARIANT and not one more: None is the null - // key, so nothing is stored for it and the storage SHIFTS LEFT — - // the key k lives at index k-1 (docs/SPEC-TABLES.md §2.4). The bound is - // E.Max, the same count `[E.Max]T` resolves to, so the two - // spellings share one projection and one protocol id. - out.Array = ir.ArrayFixed - out.ArrayBound = out.KeyEnumRef.Max - out.ArrayExpr = f.Array.Hi - default: - hi, ok := c.evalInt(f.Array.Hi) - if !ok { - return nil - } - if !hi.IsInt64() || hi.Int64() < 1 { - c.errf(f.Pos, "array bound %s below 1 (SPEC §4.6)", hi) + // `placements []Placement` — an UNBOUNDED ARRAY + // (docs/SPEC-TABLES.md §2.9). The ELEMENT is an array element like any + // other, so it has already resolved through the path above and the + // bounded array's own refusals have already fired; what is left is the + // construct's own rules, and there is no bound to evaluate. + if f.Array.Kind == ast.ArrayList { + if !c.checkListSpelling(f, inTable) { return nil } - if hi.Int64() > math.MaxInt32 { - c.errf(f.Pos, "array bound %s above %d — counts live in int32 storage (SPEC §4.3, §6.1)", hi, math.MaxInt32) + out.Array = ir.ArrayList + } else { + // an ENUM-KEYED array: the bound NAMES a declared enum rather than + // evaluating to a count — `ships [ShipType]ShipConfig`, one slot per + // variant, indexed by the variant (docs/SPEC-TABLES.md §2.4) + if !c.resolveKeyBound(f, out) { return nil } - out.ArrayBound = hi.Int64() - out.ArrayExpr = f.Array.Hi - switch f.Array.Kind { - case ast.ArrayFixed: + switch { + case out.KeyEnum != "": + if out.Type.Pointer { + // a KEYED array of pointers is a named follow-on (docs/SPEC-TABLES.md + // §2.4, §15); the bounded spellings `[N]*T` and `[..N]*T` serve (§2.1) + c.errf(f.Type.Pos, "field %s: [%s]*%s is a named follow-on — an enum-keyed array of pointers; declare [..N]*%s or [N]*%s, or key an array of tables by value (docs/SPEC-TABLES.md §2.4, §15)", + f.Name, out.KeyEnum, f.Type.Name, f.Type.Name, f.Type.Name) + return nil + } + if _, isUnion := out.Type.Ref.(*ir.Union); isUnion { + // a KEYED array of unions is a named follow-on (docs/SPEC-TABLES.md + // §2.6, §15); the bounded spellings `[N]U` and `[..N]U` serve + c.errf(f.Type.Pos, "field %s: [%s]%s is a named follow-on — an enum-keyed array of unions; declare [..N]%s or [N]%s (docs/SPEC-TABLES.md §2.6, §15)", + f.Name, out.KeyEnum, f.Type.Name, f.Type.Name, f.Type.Name) + return nil + } + // ONE SLOT PER NAMED VARIANT and not one more: None is the null + // key, so nothing is stored for it and the storage SHIFTS LEFT — + // the key k lives at index k-1 (docs/SPEC-TABLES.md §2.4). The bound is + // E.Max, the same count `[E.Max]T` resolves to, so the two + // spellings share one projection and one protocol id. out.Array = ir.ArrayFixed - case ast.ArrayUpTo: - out.Array = ir.ArrayCounted - case ast.ArrayRange: - out.Array = ir.ArrayCounted - lo, ok := c.evalInt(f.Array.Lo) + out.ArrayBound = out.KeyEnumRef.Max + out.ArrayExpr = f.Array.Hi + default: + hi, ok := c.evalInt(f.Array.Hi) if !ok { return nil } - if lo.Sign() < 0 || !lo.IsInt64() || lo.Int64() >= hi.Int64() { - c.errf(f.Pos, "array count range [%s..%s] requires 0 <= Min < N (SPEC §4.6)", lo, hi) + if !hi.IsInt64() || hi.Int64() < 1 { + c.errf(f.Pos, "array bound %s below 1 (SPEC §4.6)", hi) + return nil + } + if hi.Int64() > math.MaxInt32 { + c.errf(f.Pos, "array bound %s above %d — counts live in int32 storage (SPEC §4.3, §6.1)", hi, math.MaxInt32) return nil } - out.ArrayMin = lo.Int64() + out.ArrayBound = hi.Int64() + out.ArrayExpr = f.Array.Hi + switch f.Array.Kind { + case ast.ArrayFixed: + out.Array = ir.ArrayFixed + case ast.ArrayUpTo: + out.Array = ir.ArrayCounted + case ast.ArrayRange: + out.Array = ir.ArrayCounted + lo, ok := c.evalInt(f.Array.Lo) + if !ok { + return nil + } + if lo.Sign() < 0 || !lo.IsInt64() || lo.Int64() >= hi.Int64() { + c.errf(f.Pos, "array count range [%s..%s] requires 0 <= Min < N (SPEC §4.6)", lo, hi) + return nil + } + out.ArrayMin = lo.Int64() + } } } } @@ -3261,6 +3273,22 @@ func (c *checker) checkClaimedNames() { add(base+verb, whyMap, d.DeclPos()) } } + // AND AN UNBOUNDED ARRAY claims three names on the table + // that declares it: followed by Add, Each and + // Erase (docs/SPEC-TABLES.md §2.9, §11). Three where a map + // claims eight, and the difference is the key on both sides: + // an append needs none, so there is no entry to name, no + // insert, no find and no index to accelerate. + whyList := fmt.Sprintf("%s's generated unbounded-array surface (docs/SPEC-TABLES.md §2.9, §11)", name) + for _, f := range st.Fields { + if !f.IsList() { + continue + } + base := name + ir.GoExportName(f.Name) + for _, verb := range ir.ListFieldVerbs { + add(base+verb, whyList, d.DeclPos()) + } + } } } } diff --git a/internal/check/tablelist.go b/internal/check/tablelist.go new file mode 100644 index 0000000000..8fc23d6880 --- /dev/null +++ b/internal/check/tablelist.go @@ -0,0 +1,59 @@ +// Unbounded arrays in the checker (docs/SPEC-TABLES.md §2.9): the `[]T` +// spelling, the placements it takes and the refusals §11 states, each by name. +// +// An unbounded array is §2.8's map with the KEY and the SORT taken out, so +// almost nothing here is new machinery: the ELEMENT resolves through the +// ordinary array path, so whatever `[..N]T` admits `[]T` admits and whatever +// `[..N]T` refuses `[]T` refuses on the bounded array's own diagnostic. What +// this file adds is the placements the construct is refused in and the +// qualifications it does not take. +package check + +import ( + "github.com/mas-bandwidth/schema/v2/internal/ast" +) + +// checkListSpelling holds every rule a `[]T` field carries that is not the +// element's own (docs/SPEC-TABLES.md §2.9, §11). It runs BEFORE the element is +// resolved, so a refused placement draws one diagnostic rather than the +// element's too, and it returns false when the field is refused. +func (c *checker) checkListSpelling(f *ast.Field, inTable bool) bool { + if !inTable { + c.errf(f.Pos, "field %s: an UNBOUNDED ARRAY is a table-only construct — a `type`'s wire is positional and same-or-refuse, so nothing riding it can be unbounded; hold the []T in a `table` body, or bound it as [..N]T (docs/SPEC-TABLES.md §2.9, §11)", + f.Name) + return false + } + if c.arm != "" { + // AN ARM MAY NOT (docs/SPEC-TABLES.md §2.9, §2.6, §11), on the ground + // a `map` is refused there: both put their elements in the holder's + // NODE EXTENT, and an arm's storage is overlaid, so the extent would + // depend on the union's tag + c.errf(f.Pos, "%s: a []T is not an arm — its elements live in the holder's NODE EXTENT and an arm's storage is OVERLAID, so the extent would depend on the tag; wrap the list in a table and make THAT the arm, which is the refusal a map takes here on the same ground (docs/SPEC-TABLES.md §2.9, §2.6, §11)", + c.arm) + return false + } + if f.Type.Optional { + c.errf(f.Pos, "field %s: there is no ?[]T — a fresh list is empty, an empty list is elided, and its count's zero IS its absence, so a presence bit beside it would be two answers to one question; drop the ? (docs/SPEC-TABLES.md §2.9, §11)", + f.Name) + return false + } + if f.Default != nil { + c.errf(f.Pos, "field %s: a []T takes no specified default — a fresh list is empty, and empty is the only value a default could name (docs/SPEC-TABLES.md §2.9, §11)", + f.Name) + return false + } + for i := range f.Attrs { + a := &f.Attrs[i] + switch a.Key { + case "was", "json": + // a list is renamed under `was` as any field is, and takes a + // `json` key as any field does: both are about the field, not the + // construct (docs/SPEC-TABLES.md §2.9, §5, §16.4) + default: + c.errf(a.Pos, "field %s: %s does not apply to an unbounded array — THE COUNT IS THE DATA'S, and a bound would buy only a CLAMP, which drops a tail; drop the qualification, or declare the array at a bound, [..N]T, which is the same bytes with a bound (docs/SPEC-TABLES.md §2.9, §11)", + f.Name, a.Key) + return false + } + } + return true +} diff --git a/internal/check/tables_test.go b/internal/check/tables_test.go index 6f2cc6b132..0e2f46a2d0 100644 --- a/internal/check/tables_test.go +++ b/internal/check/tables_test.go @@ -332,6 +332,54 @@ func TestTableRefusals(t *testing.T) { {name: "a declaration named TableMapIndex is refused", want: "TABLE-wire runtime", src: "package t\nenum TableMapIndex { A, B }\ntable Tab { x int32 }\n"}, + // ---- UNBOUNDED ARRAYS (docs/SPEC-TABLES.md §2.9, §11) ---- + // + // The element set is `[..N]T`'s exactly, so every refusal below is + // either the construct's own placement rule or the bounded array's + // own diagnostic met one spelling over. + {name: "a []T in a type body is refused by name", want: "an UNBOUNDED ARRAY is a table-only construct", + src: "package t\ntype P { xs []int32 }\n"}, + {name: "a []T as a union arm is refused by name", want: "a []T is not an arm", + src: "package t\ntable E { a uint32 }\nunion U { xs []E }\ntable Tab { u U }\n"}, + {name: "[..]T names []T as the fix", want: "[..]T is not the unbounded array", + src: "package t\ntable E { a uint32 }\ntable Tab { xs [..]E }\n"}, + {name: "[Min..]T names []T as the fix", want: "[Min..]T is not the unbounded array", + src: "package t\ntable E { a uint32 }\ntable Tab { xs [0..]E }\n"}, + {name: "?[]T is refused by name", want: "there is no ?[]T", + src: "package t\ntable E { a uint32 }\ntable Tab { xs ?[]E }\n"}, + {name: "a default on a []T is refused by name", want: "a []T takes no specified default", + src: "package t\ntable Tab { xs []int32 = 0 }\n"}, + {name: "a qualification on a []T is refused by name", want: "does not apply to an unbounded array", + src: "package t\ntable Tab { xs []int32 | max = 4 }\n"}, + // the bounded spellings of the construct itself, and the element set's + // own four refusals, each on the bounded array's own diagnostic + {name: "[][]T is an array of arrays", want: "an array of arrays is not supported in v1", + src: "package t\ntable Tab { xs [][]int32 }\n"}, + {name: "[..N][]T is an array of arrays", want: "an array of arrays is not supported in v1", + src: "package t\ntable Tab { xs [..4][]int32 }\n"}, + {name: "[N][]T is an array of arrays", want: "an array of arrays is not supported in v1", + src: "package t\ntable Tab { xs [4][]int32 }\n"}, + {name: "[]map takes the map's own bound refusal", want: "a map takes no array bound", + src: "package t\ntable Tab { xs []map[uint32]int32 }\n"}, + {name: "[]*bytes takes the byte buffer's own array refusal", want: "an array of byte buffers is a named follow-on", + src: "package t\ntable Tab { xs []*bytes }\n"}, + // a table that holds a [] of ITSELF by value closes a by-value cycle, + // and a []*Self is the ordinary legal recursion through a pointer + {name: "a [] of ITSELF closes a by-value cycle", want: "type composition cycle", + src: "package t\ntable Tab { xs []Tab }\n"}, + {name: "a [] of a table that holds one closes a by-value cycle too", want: "type composition cycle", + src: "package t\ntable A { xs []B }\ntable B { ys []A }\n"}, + // AN UNBOUNDED ARRAY CLAIMS THREE NAMES against its field, and the + // difference from the map's eight is the key on both sides + {name: "a declaration under the claimed Add name is refused", want: "TabXsAdd", + src: "package t\ntable TabXsAdd { a int32 }\ntable Tab { xs []int32 }\n"}, + {name: "a declaration under the claimed Each name is refused", want: "TabXsEach", + src: "package t\ntype TabXsEach { a int32 }\ntable Tab { xs []int32 }\n"}, + {name: "a declaration under the claimed Erase name is refused", want: "TabXsErase", + src: "package t\nenum TabXsErase { A, B }\ntable Tab { xs []int32 }\n"}, + {name: "a declaration named TableList is refused", want: "TABLE-wire runtime", + src: "package t\nenum TableList { A, B }\ntable Tab { x int32 }\n"}, + {name: "a pointer to an undeclared table", want: "undefined type", src: "package t\ntable Tab { head *Ghost }\n"}, {name: "by-value recursion stays refused with pointers in the language", diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 5fddd14507..e004bc4283 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -502,6 +502,7 @@ func (p *parser) parseFieldLine(t scanner.Token) ast.Item { } if p.kind() == scanner.LBrack { f.Array = p.parseArrayBound() + p.refuseArrayOfArrays() } if p.kind() == scanner.KwMap { // `ships map[string(32)]ShipConfig` — a MAP (docs/SPEC-TABLES.md @@ -579,6 +580,7 @@ func (p *parser) parseMapType() *ast.MapType { } if p.kind() == scanner.LBrack { value.Array = p.parseArrayBound() + p.refuseArrayOfArrays() } if p.kind() == scanner.KwMap { optional := value.Type.Optional @@ -593,6 +595,20 @@ func (p *parser) parseMapType() *ast.MapType { return m } +// refuseArrayOfArrays refuses a SECOND bracket where the element type stands +// — `[][]T`, `[][..N]T`, `[..N][]T` and `[N][]T` — by name, and then consumes +// the inner bound so the rest of the file's diagnostics still land. Arrays of +// arrays are not in v1 (SPEC §4.3), and the fix an unbounded array's element +// takes is a TABLE wrapper rather than a `type` wrapper, because a `type` body +// refuses a `[]T` (docs/SPEC-TABLES.md §2.9, §11). +func (p *parser) refuseArrayOfArrays() { + if p.kind() != scanner.LBrack { + return + } + p.errf(p.tok().Pos, "an array of arrays is not supported in v1 — wrap the inner array in a TABLE and declare an array of that table, which is the wrapper an unbounded array's element takes because a `type` body refuses a []T (SPEC §4.3, docs/SPEC-TABLES.md §2.9, §11)") + p.parseArrayBound() // consumed so the rest of the file's diagnostics land +} + func (p *parser) parseArrayBound() *ast.ArrayBound { p.expect(scanner.LBrack, "[") b := &ast.ArrayBound{} @@ -606,15 +622,36 @@ func (p *parser) parseArrayBound() *ast.ArrayBound { p.advance() b.Kind = ast.ArrayUpTo b.Hi = p.parseExpr() + case scanner.RBrack: + // [] — an UNBOUNDED ARRAY (docs/SPEC-TABLES.md §2.9). The bracket is + // the one every extent uses and an EMPTY bracket is the absence of an + // extent, which is what the construct is. + b.Kind = ast.ArrayList case scanner.DotDot: - // [..N] — sugar for [0..N], reads "up to N" (SPEC §4.3) p.advance() + if p.kind() == scanner.RBrack { + // [..]T — refused by name (docs/SPEC-TABLES.md §2.9): `[..N]` is a + // BOUND, so dropping its N reads as a bound someone failed to + // finish rather than a bound nobody declared, and the grammar's own + // Bound production has no such form + p.errf(p.tok().Pos, "[..]T is not the unbounded array — `[..N]` is a BOUND, so dropping its N reads as a bound left unfinished rather than a bound nobody declared, and a count bound is a range LITERAL and never a truncated one; spell an unbounded array [] (docs/SPEC-TABLES.md §2.9, SPEC §4.2)") + b.Kind = ast.ArrayList + break + } + // [..N] — sugar for [0..N], reads "up to N" (SPEC §4.3) b.Kind = ast.ArrayUpTo b.Hi = p.parseExpr() default: first := p.parseExpr() if p.kind() == scanner.DotDot { p.advance() + if p.kind() == scanner.RBrack { + // [0..]T — refused by name (docs/SPEC-TABLES.md §2.9): it + // states a minimum and hides the missing maximum behind it + p.errf(p.tok().Pos, "[Min..]T is not the unbounded array — it states a minimum and hides the missing maximum behind it, and a count bound is a range LITERAL and never a truncated one; spell an unbounded array [], or complete the bound as [Min..N] (docs/SPEC-TABLES.md §2.9, SPEC §4.2)") + b.Kind = ast.ArrayList + break + } b.Kind = ast.ArrayRange b.Lo = first b.Hi = p.parseExpr() diff --git a/internal/tablenames/cpp.go b/internal/tablenames/cpp.go index 76eeefd87b..90877eabda 100644 --- a/internal/tablenames/cpp.go +++ b/internal/tablenames/cpp.go @@ -36,6 +36,11 @@ func init() { // freed now is a collision the day the codec lands. Name{Name: "TableMap", What: "a map field's sorted entry array and its count"}, Name{Name: "TableMapIndex", What: "a map's side index, built once and searched in place"}, + // the UNBOUNDED ARRAY's storage (docs/SPEC-TABLES.md §2.9), claimed on + // TableMap's exact terms and for its reason: the map's slot with the + // key taken out, emitted only into a unit that declares a `[]T` and + // claimed in every unit that declares a table. + Name{Name: "TableList", What: "an unbounded array's element reference and its count"}, // C++'s float <-> IEEE-754 bit pattern helpers Name{Name: "table_bits_to_float", What: "u32 bits -> float"}, Name{Name: "table_float_to_bits", What: "float -> u32 bits"}, diff --git a/internal/tablepack/pack.go b/internal/tablepack/pack.go index b1d36d2013..e6f640747a 100644 --- a/internal/tablepack/pack.go +++ b/internal/tablepack/pack.go @@ -246,7 +246,7 @@ func (p *packer) arrayDir(fv *tabletext.Field, dir string, entries []os.DirEntry } placed++ } - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { fv.Count = placed } } diff --git a/internal/tabletext/read.go b/internal/tabletext/read.go index 292d78ba8a..cc0d222121 100644 --- a/internal/tabletext/read.go +++ b/internal/tabletext/read.go @@ -924,6 +924,15 @@ func (in *reader) readArray(fv *Field, depth int) bool { placed := 0 shape := ElementShape(f) bound := int(f.ArrayBound) + if f.Array == ir.ArrayList { + // EVERY ELEMENT THE TEXT CARRIES IS READ, because there is no bound to + // drop a tail against (docs/SPEC-TABLES.md §2.9, §16.2): the bounded + // array's "more than N are dropped, counted" row has no counterpart + // here. The int32 storage cap is the only limit, and it is the cap + // §2.2 gives every count. + bound = math.MaxInt32 + fv.Elems = fv.Elems[:0] + } for { c := in.peek() if c == ']' { @@ -934,6 +943,9 @@ func (in *reader) readArray(fv *Field, depth int) bool { in.bad = true return false } + if f.Array == ir.ArrayList && placed == len(fv.Elems) { + fv.Elems = append(fv.Elems, in.m.elementZero(f)) + } switch { case placed >= bound: // more elements than the reader's bound: the bounded prefix is diff --git a/internal/tabletext/value.go b/internal/tabletext/value.go index d8e299b1b4..1d2ebc89e4 100644 --- a/internal/tabletext/value.go +++ b/internal/tabletext/value.go @@ -538,3 +538,9 @@ func WideValue(cell *Cell) *big.Int { } return cell.Wide } + +// ElementZero is one value-initialized array slot — what an UNBOUNDED ARRAY's +// decode appends per element it reaches (docs/SPEC-TABLES.md §2.9), since a +// list's slots are grown against the body rather than sized from the +// declaration. +func (m *Model) ElementZero(f *ir.Field) Cell { return m.elementZero(f) } diff --git a/internal/tabletext/write.go b/internal/tabletext/write.go index e5ced9074e..3000c03129 100644 --- a/internal/tabletext/write.go +++ b/internal/tabletext/write.go @@ -121,7 +121,7 @@ func (m *Model) countField(g *graphOut, fv *Field, open map[*Instance]bool) erro return nil case f.Array != ir.ArrayNone: count := int(f.ArrayBound) - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { count = fv.Count } for i := 0; i < count; i++ { @@ -315,7 +315,7 @@ func (m *Model) writeField(w *writer, fv *Field, depth int) error { return m.writeKeyed(w, fv, depth) case f.Array != ir.ArrayNone: count := int(f.ArrayBound) - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { count = fv.Count } if count == 0 { diff --git a/internal/tablewire/decode.go b/internal/tablewire/decode.go index 6931f504bd..d113c21c5c 100644 --- a/internal/tablewire/decode.go +++ b/internal/tablewire/decode.go @@ -631,7 +631,7 @@ func (r *wireReader) arrayBody(fv *tabletext.Field, framed int) (ok, selected bo if f.Type.Kind == ir.TBytes { bound = int(f.Type.Size) } - counted := f.Type.Kind == ir.TBytes || f.Array == ir.ArrayCounted + counted := f.Type.Kind == ir.TBytes || f.CountedOnWire() bodyLen := framed if framed < 0 { n, good := r.leb() @@ -678,7 +678,24 @@ func (r *wireReader) arrayBody(fv *tabletext.Field, framed int) (ok, selected bo return true, false } keep := int(count) - if count > uint64(bound) { + switch { + case f.Array == ir.ArrayList: + // AN UNBOUNDED ARRAY HAS NO BOUND TO CLAMP AGAINST, and that is + // the one counter this construct removes (§2.9): a count is read, + // or refused before the read, or damaged. A count above the int32 + // STORAGE CAP is the refusal LoadBuilder answers NULL for — the + // partial value is discarded and the report holds what it held — + // so the walk stops here rather than sizing a slot list from a + // number the wire chose. + if count > uint64(math.MaxInt32) { + r.report.Malformed = true + r.off = end + return false, false + } + // the slots are grown ONE AT A TIME below, against the body's own + // length, so a count no body can cover allocates nothing + fv.Elems = fv.Elems[:0] + case count > uint64(bound): keep = bound r.report.Clamped++ } @@ -700,11 +717,17 @@ func (r *wireReader) arrayBody(fv *tabletext.Field, framed int) (ok, selected bo fv.Cell.Str = payload } else { for i := 0; i < keep; i++ { + if f.Array == ir.ArrayList { + fv.Elems = append(fv.Elems, r.m.ElementZero(f)) + } if !sub.element(fv, i) { break } decoded = i + 1 } + if f.Array == ir.ArrayList { + fv.Elems = fv.Elems[:decoded] // the prefix the body covered + } } if counted { fv.Count = decoded diff --git a/internal/tablewire/encode.go b/internal/tablewire/encode.go index c785695530..8f32a916d0 100644 --- a/internal/tablewire/encode.go +++ b/internal/tablewire/encode.go @@ -179,7 +179,7 @@ func encodeField(e *encoder, w *buf, inst *tabletext.Instance, fv *tabletext.Fie // body — and every declared element for the fixed one. No content // test anywhere: presence already decided. count := int(f.ArrayBound) - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { count = fv.Count } return encodeArray(e, w, fv, id, kind, count) @@ -223,7 +223,10 @@ func encodeField(e *encoder, w *buf, inst *tabletext.Instance, fv *tabletext.Fie w.raw(fv.Cell.Str) return nil - case f.Array == ir.ArrayCounted: + case f.CountedOnWire(): + // AN UNBOUNDED ARRAY IS THE COUNTED ARRAY'S OWN BYTES (§2.9): the + // same kind 14 body, the same element kind, the same count, and the + // same elision of an empty one under §3's by-value elision rule. if fv.Count == 0 { return nil } @@ -542,7 +545,7 @@ func encodeArm(e *encoder, arm ir.UnionVariant, cell *tabletext.Cell) ([]byte, e w.raw(fv.Cell.Str) case f.Array != ir.ArrayNone: count := int(f.ArrayBound) - if f.Array == ir.ArrayCounted { + if f.CountedOnWire() { count = fv.Count } elemKind := kind diff --git a/internal/tablewire/list_test.go b/internal/tablewire/list_test.go new file mode 100644 index 0000000000..650dbcff75 --- /dev/null +++ b/internal/tablewire/list_test.go @@ -0,0 +1,384 @@ +// The UNBOUNDED ARRAY on the wire and in the text (docs/SPEC-TABLES.md §2.9), +// as the tool's two halves carry it. Every case here is one of the section's +// own claims, and each names the sabotage it goes red for. +package tablewire_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/mas-bandwidth/schema/v2/compiler" + "github.com/mas-bandwidth/schema/v2/internal/tabletext" + "github.com/mas-bandwidth/schema/v2/internal/tablewire" + "github.com/mas-bandwidth/schema/v2/ir" +) + +// listUnit is §2.9's own example, and listBoundUnit is the SAME CONTENT under +// bounded declarations — the two halves of the `list_migrates` claim. +const listUnit = `package save + +table Placement +{ + x float32 + y float32 + model uint32 +} + +table LogEntry { tick uint32 } + +table Save +{ + placements []Placement + log []*LogEntry + scores []int32 +} +` + +const listBoundUnit = `package save + +table Placement +{ + x float32 + y float32 + model uint32 +} + +table LogEntry { tick uint32 } + +table Save +{ + placements [..8]Placement + log [..8]*LogEntry + scores [..8]int32 +} +` + +// the section's own text, with a `null` slot and a shared `&node`. +const listText = `{ + "placements": [ { "x": 1.0, "y": 2.0, "model": 3 }, + { "x": 3.0, "y": 4.0, "model": 7 } ], + "log": [ { "&node": 1, "tick": 7 }, { "&node": 1 }, null ], + "scores": [ 10, 20, 30 ] +}` + +func listModel(t *testing.T, src string) *tabletext.Model { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Save.schema"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + c := compiler.New() + paths, err := compiler.GatherPaths([]string{dir}) + if err != nil { + t.Fatal(err) + } + u, err := c.Load(paths) + if err != nil { + t.Fatal(err) + } + return tabletext.NewModel(u) +} + +// TestListWireIsTheBoundedArrayWire is `list_migrates` as a unit test: ONE +// content, TWO declarations of the holder, and the same bytes from both. It is +// what makes the bound a declaration-side fact and never a wire fact (§2.9). +func TestListWireIsTheBoundedArrayWire(t *testing.T) { + unbounded, err := tablewire.Encode(listModel(t, listUnit), place(t, listModel(t, listUnit), "Save", listText)) + if err != nil { + t.Fatal(err) + } + bounded, err := tablewire.Encode(listModel(t, listBoundUnit), place(t, listModel(t, listBoundUnit), "Save", listText)) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(unbounded, bounded) { + t.Fatalf("[]T and [..N]T are one wire (§2.9): %d bytes against %d, and they differ", len(unbounded), len(bounded)) + } + // and the bounded declaration READS the unbounded writer's bytes back to + // equal values, in silence, which is the other half of the claim + m := listModel(t, listBoundUnit) + back := m.New(m.Lookup("Save")) + var r tabletext.Report + ok, err := tablewire.Decode(m, back, unbounded, &r) + if err != nil || !ok { + t.Fatalf("the bounded reader refused the unbounded writer's bytes: ok=%v err=%v", ok, err) + } + if !r.Silent() { + t.Fatalf("the read was not silent: %+v", r) + } + again, err := tablewire.Encode(m, back) + if err != nil || !bytes.Equal(again, unbounded) { + t.Fatalf("the bounded read did not reproduce the value: %v", err) + } +} + +// TestListRoundTrip is the wire and the text over one another: the elements in +// INDEX order, a `[]*T`'s two slots naming ONE node, and a null slot. +func TestListRoundTrip(t *testing.T) { + m := listModel(t, listUnit) + inst := place(t, m, "Save", listText) + wire, err := tablewire.Encode(m, inst) + if err != nil { + t.Fatal(err) + } + back := m.New(m.Lookup("Save")) + var r tabletext.Report + ok, err := tablewire.Decode(m, back, wire, &r) + if err != nil || !ok || !r.Silent() { + t.Fatalf("decode: ok=%v err=%v report=%+v", ok, err, r) + } + again, err := tablewire.Encode(m, back) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(wire, again) { + t.Fatal("the round trip did not reproduce the wire: measure == save over a list is the arithmetic alone (§2.9)") + } + // THE COUNTS ARE THE DATA'S: three lists, at 2, 3 and 3 + for _, want := range []struct { + field string + count int + }{{"placements", 2}, {"log", 3}, {"scores", 3}} { + fv := fieldByName(t, back, want.field) + if fv.Count != want.count || len(fv.Elems) != want.count { + t.Fatalf("%s: count %d over %d slots, want %d of each", want.field, fv.Count, len(fv.Elems), want.count) + } + } + // A SHARED NODE IS ONE NODE: the two `&node` slots resolve to one record, + // and the third slot is null + log := fieldByName(t, back, "log") + if log.Elems[0].Node == nil || log.Elems[0].Node != log.Elems[1].Node { + t.Fatal("two slots naming one node hold one node (§2.9, §3.1)") + } + if log.Elems[2].Node != nil { + t.Fatal("a null element of a []*T is a null slot (§16.2)") + } +} + +// TestListEmptyElides is §3's by-value elision rule at this construct: a fresh +// list is empty, an empty list is elided, and its count's zero IS its absence +// — which is why there is no `?[]T`. +func TestListEmptyElides(t *testing.T) { + m := listModel(t, listUnit) + empty, err := tablewire.Encode(m, place(t, m, "Save", `{}`)) + if err != nil { + t.Fatal(err) + } + one, err := tablewire.Encode(m, place(t, m, "Save", `{ "scores": [ 1 ] }`)) + if err != nil { + t.Fatal(err) + } + // three empty lists and nothing else: the form byte, the body's own + // terminator and an id table of zero entries, and not one field header + if len(empty) != 10 { + t.Fatalf("an empty list rides NO bytes at all: %d, want the form byte, the terminator and an empty id table: %x", len(empty), empty) + } + if len(one) <= len(empty) { + t.Fatalf("one element rides: %d against %d empty", len(one), len(empty)) + } + back := m.New(m.Lookup("Save")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, empty, &r); !ok || err != nil || !r.Silent() { + t.Fatalf("an all-empty save did not read back silently: ok=%v err=%v %+v", ok, err, r) + } + if fieldByName(t, back, "scores").Count != 0 { + t.Fatal("an elided list reads back empty") + } +} + +// TestListTextReadsEveryElement is §16.2's row for the construct: EVERY +// element the text carries is read, because there is no bound to drop a tail +// against, and `clamped` cannot fire on the count. +func TestListTextReadsEveryElement(t *testing.T) { + m := listModel(t, listUnit) + var b bytes.Buffer + b.WriteString(`{ "scores": [`) + for i := range 100 { + if i > 0 { + b.WriteString(",") + } + b.WriteString("1") + } + b.WriteString("] }") + inst := m.New(m.Lookup("Save")) + var r tabletext.Report + if !m.Read(inst, b.Bytes(), &r) { + t.Fatal("the text did not place") + } + if r.Clamped != 0 { + t.Fatalf("clamped cannot fire on a list's count (§2.9): %d", r.Clamped) + } + if got := fieldByName(t, inst, "scores").Count; got != 100 { + t.Fatalf("every element the text carries is read: %d of 100", got) + } + // and `null` where an element stands is a kind_mismatch, the array row's + // own rule, because the element is not a pointer + var nulls tabletext.Report + if !m.Read(m.New(m.Lookup("Save")), []byte(`{ "scores": [ 1, null, 3 ] }`), &nulls) { + t.Fatal("the text did not place") + } + if nulls.KindMismatch != 1 { + t.Fatalf("null is a kind_mismatch at a scalar element (§16.2): %d", nulls.KindMismatch) + } +} + +// TestListCountOverLengthIsFramingDamage is §2.9's first overflow row on the +// BUILDER side: a count the body cannot cover lands the prefix the body +// covers, counts `malformed`, and the parent reads on past the field's L. +func TestListCountOverLength(t *testing.T) { + m := listModel(t, listUnit) + wire, err := tablewire.Encode(m, place(t, m, "Save", `{ "scores": [ 10, 20, 30 ] }`)) + if err != nil { + t.Fatal(err) + } + // the scores body is `kind 4 (i32)` then the count 3 then twelve bytes; + // raising the count to 30 leaves an N the L cannot carry + damaged := append([]byte(nil), wire...) + at := bytes.Index(damaged, []byte{byte(ir.TableKindI32), 3, 10, 0, 0, 0}) + if at < 0 { + t.Fatal("the scores body is not where this test expects it") + } + damaged[at+1] = 30 + back := m.New(m.Lookup("Save")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, damaged, &r); !ok || err != nil { + t.Fatalf("the walk stopped rather than reporting: ok=%v err=%v", ok, err) + } + if !r.Malformed { + t.Fatal("a count the body cannot cover is framing damage (§2.9, §4)") + } + if r.Clamped != 0 { + t.Fatalf("clamped cannot fire on a list's count (§2.9): %d", r.Clamped) + } + if got := fieldByName(t, back, "scores").Count; got != 3 { + t.Fatalf("the prefix the body covers lands: %d of 3", got) + } +} + +// TestListElementKindMismatch is §3's element-kind rule at this construct: the +// field is skipped whole by its L, the list reads empty, and one +// `kind_mismatch` counts. +func TestListElementKindMismatch(t *testing.T) { + m := listModel(t, listUnit) + wire, err := tablewire.Encode(m, place(t, m, "Save", `{ "scores": [ 10, 20, 30 ] }`)) + if err != nil { + t.Fatal(err) + } + swapped := append([]byte(nil), wire...) + at := bytes.Index(swapped, []byte{byte(ir.TableKindI32), 3, 10, 0, 0, 0}) + if at < 0 { + t.Fatal("the scores body is not where this test expects it") + } + swapped[at] = byte(ir.TableKindF32) // []int32 read as []float32 + back := m.New(m.Lookup("Save")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, swapped, &r); !ok || err != nil { + t.Fatalf("decode: ok=%v err=%v", ok, err) + } + if r.KindMismatch != 1 || r.Malformed { + t.Fatalf("an element kind that disagrees is one kind_mismatch and no damage: %+v", r) + } + if got := fieldByName(t, back, "scores").Count; got != 0 { + t.Fatalf("the list reads empty: %d", got) + } +} + +// TestListWalkOrder is `list_before_pointer`: a `[]*T` DECLARED BEFORE a +// pointer field reaches its shared node FIRST and numbers it first, because +// the numbering is one walk over the fields in declaration order that descends +// each by-value edge WHERE IT IS DECLARED (§2.9, §3.1). It is +// `stream_arm_first`'s shape at this construct. +func TestListWalkOrder(t *testing.T) { + const src = `package walk + +table Mark { tick uint32 } + +table Head +{ + marks []*Mark + solo *Mark +} +` + m := listModel(t, src) + // the list's own node is declared first, so it takes index 2 (record 1) + // and the pointer field's takes index 3 + inst := place(t, m, "Head", `{ "marks": [ { "&node": 1, "tick": 7 } ], "solo": { "&node": 2, "tick": 9 } }`) + wire, err := tablewire.Encode(m, inst) + if err != nil { + t.Fatal(err) + } + // the FIRST record body after the root carries tick 7, the list's node: + // find the two tick payloads in the order they were written + first := bytes.Index(wire, []byte{byte(ir.TableKindU32), 7, 0, 0, 0}) + second := bytes.Index(wire, []byte{byte(ir.TableKindU32), 9, 0, 0, 0}) + if first < 0 || second < 0 { + t.Fatalf("both records ride: %d and %d", first, second) + } + if first > second { + t.Fatal("a []*T declared before a pointer field reaches its node first and numbers it first (§2.9, §3.1)") + } + back := m.New(m.Lookup("Head")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, wire, &r); !ok || err != nil || !r.Silent() { + t.Fatalf("decode: ok=%v err=%v %+v", ok, err, r) + } + again, err := tablewire.Encode(m, back) + if err != nil || !bytes.Equal(again, wire) { + t.Fatal("the round trip did not reproduce the numbering") + } +} + +// TestListOfTablesReachesTheirEdges: a list of TABLES is a by-value edge, so +// each element is descended for the pointer slots inside it before the next +// element is reached (§2.9, §3.1). It is the `list_nested` shape at one depth. +func TestListOfTablesReachesTheirEdges(t *testing.T) { + const src = `package deep + +table Leaf { tick uint32 } + +table Row { leaf *Leaf } + +table Sheet { rows []Row } +` + m := listModel(t, src) + inst := place(t, m, "Sheet", `{ "rows": [ { "leaf": { "&node": 1, "tick": 7 } }, { "leaf": { "&node": 1 } }, { "leaf": null } ] }`) + wire, err := tablewire.Encode(m, inst) + if err != nil { + t.Fatal(err) + } + back := m.New(m.Lookup("Sheet")) + var r tabletext.Report + if ok, err := tablewire.Decode(m, back, wire, &r); !ok || err != nil || !r.Silent() { + t.Fatalf("decode: ok=%v err=%v %+v", ok, err, r) + } + rows := fieldByName(t, back, "rows") + if rows.Count != 3 { + t.Fatalf("three rows ride: %d", rows.Count) + } + // ONE NODE reached from inside two elements, and a null in the third + a := rows.Elems[0].Tab.Fields[0].Cell.Node + b := rows.Elems[1].Tab.Fields[0].Cell.Node + if a == nil || a != b { + t.Fatal("a node named from two elements is one node (§2.9, §3.1)") + } + if rows.Elems[2].Tab.Fields[0].Cell.Node != nil { + t.Fatal("a null pointer inside an element stays null") + } + again, err := tablewire.Encode(m, back) + if err != nil || !bytes.Equal(again, wire) { + t.Fatal("the round trip did not reproduce the wire") + } +} + +func fieldByName(t *testing.T, inst *tabletext.Instance, name string) *tabletext.Field { + t.Helper() + for i := range inst.Fields { + if inst.Fields[i].Def.Name == name { + return &inst.Fields[i] + } + } + t.Fatalf("%s declares no field %s", inst.Def.Name, name) + return nil +} diff --git a/internal/tablewire/nodes.go b/internal/tablewire/nodes.go index 92c3697bb9..dc66194ca1 100644 --- a/internal/tablewire/nodes.go +++ b/internal/tablewire/nodes.go @@ -196,7 +196,7 @@ func visitEdges(m *tabletext.Model, inst *tabletext.Instance, visit func(target return } } - case ir.ArrayCounted: + case ir.ArrayCounted, ir.ArrayList: for k := 0; k < fv.Count && k < len(fv.Elems); k++ { if fv.Elems[k].Node != nil && !visit(Node{Inst: fv.Elems[k].Node}, f.Name) { return @@ -215,7 +215,7 @@ func visitEdges(m *tabletext.Model, inst *tabletext.Instance, visit func(target for k := range fv.Elems { visitArmEdges(m, &fv.Elems[k], un, f.Name, visit) } - case ir.ArrayCounted: + case ir.ArrayCounted, ir.ArrayList: for k := 0; k < fv.Count && k < len(fv.Elems); k++ { visitArmEdges(m, &fv.Elems[k], un, f.Name, visit) } @@ -241,8 +241,11 @@ func visitEdges(m *tabletext.Model, inst *tabletext.Instance, visit func(target visitEdges(m, sub, visit) } } - case f.Array == ir.ArrayCounted: - // only the LIVE elements ride, so only they carry edges + case f.CountedOnWire(): + // only the LIVE elements ride, so only they carry edges. An + // UNBOUNDED ARRAY is a by-value edge at its FIELD'S POSITION, + // its elements visited in index order and each descended before + // the next is reached (§2.9, §3.1) for k := 0; k < fv.Count && k < len(fv.Elems); k++ { if sub := fv.Elems[k].Tab; sub != nil { visitEdges(m, sub, visit) @@ -289,7 +292,7 @@ func visitArmEdges(m *tabletext.Model, cell *tabletext.Cell, un *ir.Union, field } case f.Type.Pointer: live := len(fv.Elems) - if f.Array == ir.ArrayCounted && fv.Count < live { + if f.CountedOnWire() && fv.Count < live { live = fv.Count } for k := 0; k < live; k++ { diff --git a/ir/blocklayout.go b/ir/blocklayout.go index 502cbda6b1..4b762c80c6 100644 --- a/ir/blocklayout.go +++ b/ir/blocklayout.go @@ -253,6 +253,8 @@ func variableEdge(u *Unit, st *Struct) string { return where + " is a pointer, and a pointer in the by-value closure means no fixed pitch anywhere in it" case f.IsMap(): return where + " is a map, and a map's entries are an extent inside the holder's own node, so there is no fixed pitch anywhere in it" + case f.IsList(): + return where + " is an unbounded array, and its elements are an extent inside the holder's own node, so there is no fixed pitch anywhere in it" } if f.Type.Kind != TNamed { continue @@ -642,6 +644,16 @@ func fieldPieces(u *Unit, f *Field, projection bool) []storagePiece { e := elementPiece(u, f) pieces = append(pieces, storagePiece{size: mulSize(e.size, f.ArrayBound), align: e.align}) pieces = append(pieces, storagePiece{size: 4, align: 4}) // int32 count + case f.Array == ArrayList: + // AN UNBOUNDED ARRAY FIELD IS SIXTEEN BYTES (docs/SPEC-TABLES.md + // §2.9): it is the map's slot exactly, because it is the same two + // facts — an int64 self-relative reference to the element array and + // an int32 count — then padding to eight. ONE piece for the map's own + // reason: both spellings are one member of a TableList type, and a + // port that walked two would account for twelve bytes where sixteen + // are written. The ELEMENTS are not here: they are by-value records + // inside the holder's node extent, after the record's own storage. + pieces = append(pieces, storagePiece{size: 16, align: 8}) default: pieces = append(pieces, elementPiece(u, f)) } diff --git a/ir/buildversion.go b/ir/buildversion.go index c767e9e448..a9613aef89 100644 --- a/ir/buildversion.go +++ b/ir/buildversion.go @@ -191,6 +191,12 @@ func cookFieldLine(u *Unit, fl FieldLayout, enums map[string]*Enum, flags map[st // which is where a key edit moves the id (§20.1). kind = TableKindArray } + if f.IsList() { + // AN UNBOUNDED ARRAY FIELD IS AN ARRAY LINE (docs/SPEC-TABLES.md + // §20.2): `kind=14`, `array=unbounded` and the element's own storage + // size in `elem=`, plus the `size=` its sixteen-byte slot produces. + kind = TableKindArray + } var b strings.Builder fmt.Fprintf(&b, " field %016x kind=%d offset=%d size=%d", TableFieldWireId(f), kind, fl.Offset, fl.Size) b.WriteString(cookFacts(u, fl, enums, flags, unions)) @@ -318,6 +324,14 @@ func cookFacts(u *Unit, fl FieldLayout, enums map[string]*Enum, flags map[string fmt.Fprintf(&b, " elem=%d array=fixed bound=%d", cookElemSize(fl), f.ArrayBound) case f.Array == ArrayCounted: fmt.Fprintf(&b, " elem=%d array=bounded bound=%d", cookElemSize(fl), f.ArrayBound) + case f.Array == ArrayList: + // `array=unbounded` is the shape and `elem=` the ELEMENT'S OWN storage + // size, the pitch its elements lie at inside the holder's node extent, + // as on every other array line here (docs/SPEC-TABLES.md §2.9, §20.2). + // There is no `bound=`, for the map's reason: it declares no extent + // and its count is a wire fact. The sixteen-byte slot is what `size=` + // on this same line already says. + fmt.Fprintf(&b, " elem=%d array=unbounded", elementPiece(u, f).size) case f.Type.Blob(): // a byte buffer has no capacity: its blob node is exactly its size case f.Type.Kind == TString, f.Type.Kind == TBytes: diff --git a/ir/buildversion_test.go b/ir/buildversion_test.go index 85571580bc..d50436b43a 100644 --- a/ir/buildversion_test.go +++ b/ir/buildversion_test.go @@ -293,3 +293,62 @@ func TestBuildVersionSeesARecordRenamed(t *testing.T) { t.Error("a record added did not move the build version") } } + +// listSource is §2.9's own example, cut to what the projection has to render: +// a list of tables and a list of scalars, and the sixteen-byte slot each takes. +const listSource = `package demo + +table Placement +{ + x float32 + y float32 +} + +table Save +{ + placements []Placement + scores []int32 +} +` + +// TestTheUnboundedArrayProjectsAsAnArrayWithNoBound (docs/SPEC-TABLES.md §2.9, +// §20.2): `kind=14`, `array=unbounded` and `elem=` the ELEMENT'S OWN storage +// size, beside the `size=` the sixteen-byte slot produces. No `bound=`, +// because it declares no extent and its count is a wire fact. +func TestTheUnboundedArrayProjectsAsAnArrayWithNoBound(t *testing.T) { + text := ir.CookProjection(unitFrom(t, listSource)) + for _, want := range []string{ + "record Save sizeof=32 alignof=8", + "kind=14 offset=0 size=16 type=Placement elem=8 array=unbounded", + "kind=14 offset=16 size=16 elem=4 array=unbounded", + } { + if !strings.Contains(text, want) { + t.Errorf("the projection does not carry %q:\n%s", want, text) + } + } + if strings.Contains(text, "array=unbounded bound=") { + t.Errorf("an unbounded array projects no bound=:\n%s", text) + } +} + +// TestTheBuildVersionMovesWhenTheListGainsABound (docs/SPEC-TABLES.md §2.9): +// a `[]T` gaining a bound moves the field's array shape and its storage, and +// a `was` rename of the list moves nothing at all. +func TestTheBuildVersionMovesWhenTheListGainsABound(t *testing.T) { + base := ir.BuildVersion(unitFrom(t, listSource)) + bounded := ir.BuildVersion(unitFrom(t, strings.Replace(listSource, "scores []int32", "scores [..4]int32", 1))) + if base == bounded { + t.Errorf("a []T given a bound does not move the build version: %016x", base) + } + renamed := ir.BuildVersion(unitFrom(t, strings.Replace(listSource, + "scores []int32", `scores_v2 []int32 | was = "scores"`, 1))) + if base != renamed { + t.Errorf("a was rename of a list moved the build version: %016x -> %016x", base, renamed) + } + // AND A LIST-FREE UNIT'S ID IS UNCHANGED BY THE CONSTRUCT EXISTING: the + // worked example above carries no list, and its digest is the one §20.2 + // pins. + if got := ir.BuildVersion(unitFrom(t, workedSource)); got != ir.BuildVersion(unitFrom(t, workedSource)) { + t.Errorf("a list-free unit's build version is not stable: %016x", got) + } +} diff --git a/ir/ir.go b/ir/ir.go index 57da56452e..59b054462c 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -268,6 +268,14 @@ const ( ArrayNone ArrayKind = iota ArrayFixed ArrayCounted // [..N]T and [Min..N]T + // ArrayList is `[]T` — an UNBOUNDED ARRAY (docs/SPEC-TABLES.md §2.9): a + // counted array whose count the DATA decides. On the wire it is a kind-14 + // body exactly as ArrayCounted is; what it drops is the declared bound, + // and with the bound goes the inline storage, so the slot holds a + // reference and a count and the elements live in the holder's node + // extent. ArrayBound stays 0, which is what the descriptors publish as + // "no declared bound" (§8.1). + ArrayList ) // Field is one storage-carrying member of a struct, with its resolved wire diff --git a/ir/table.go b/ir/table.go index afdc6534a4..e6e73e34d5 100644 --- a/ir/table.go +++ b/ir/table.go @@ -183,6 +183,14 @@ func VariableTables(u *Unit) map[string]bool { variable[name] = true break } + if f.IsList() { + // an UNBOUNDED ARRAY is a variable edge whatever its + // element is (docs/SPEC-TABLES.md §2.9), on the map's own + // terms: its elements live in the arena on the authoring + // side and in the node's own extent in a region + variable[name] = true + break + } if f.Type.Kind != TNamed { continue } diff --git a/ir/tablekind.go b/ir/tablekind.go index dd7fdb423a..3b8aeab34e 100644 --- a/ir/tablekind.go +++ b/ir/tablekind.go @@ -392,6 +392,8 @@ func FieldTypeSpelling(f *Field) string { switch { case f.KeyEnum != "": prefix = "[" + f.KeyEnum + "]" + case f.Array == ArrayList: + prefix = "[]" // an UNBOUNDED ARRAY (docs/SPEC-TABLES.md §2.9) case f.Array == ArrayFixed: prefix = "[" + itoa64(f.ArrayBound) + "]" case f.Array == ArrayCounted && f.ArrayMin > 0: diff --git a/ir/tablelist.go b/ir/tablelist.go new file mode 100644 index 0000000000..243cad97ce --- /dev/null +++ b/ir/tablelist.go @@ -0,0 +1,54 @@ +// Unbounded arrays in the IR (docs/SPEC-TABLES.md §2.9): the derived facts +// every backend and the tool's engines read off a `[]T` field. An unbounded +// array is a COUNTED ARRAY whose count the DATA decides — §2.8's map with the +// KEY and the SORT taken out — so nothing here describes a new wire construct. +// It names the surface the construct claims, the fields a backend without it +// refuses, and the one question every walk asks: does this field carry a LIVE +// COUNT the value decides. +package ir + +import "sort" + +// IsList reports a `[]T` field — an UNBOUNDED ARRAY +// (docs/SPEC-TABLES.md §2.9). +func (f *Field) IsList() bool { return f != nil && f.Array == ArrayList } + +// ListFieldVerbs is the SURFACE an unbounded array claims on the table that +// declares it (docs/SPEC-TABLES.md §2.9, §11): `
` followed by +// each of these. There is no entry type and no lookup, so the list is the +// three calls a builder needs and nothing beside them. +// +// The list is claimed with the CONSTRUCT and not with the codec, on the rule +// §11 already follows for the map's surface and the block form's row +// accessors: a name free today must not become a collision the day a backend +// emits it. +var ListFieldVerbs = []string{"Add", "Each", "Erase"} + +// ListFields lists the unbounded arrays an author WROTE, as `Table.field`, +// sorted — the names a backend that does not carry the construct puts in its +// refusal (docs/SPEC-TABLES.md §2.9, §11). +func ListFields(u *Unit) []string { + var out []string + for name := range TableClosure(u) { + st := memberStruct(u, name) + if st == nil { + continue + } + for _, f := range st.Fields { + if f.IsList() { + out = append(out, name+"."+f.Name) + } + } + } + sort.Strings(out) + return out +} + +// CountedOnWire reports a field whose array body carries a LIVE COUNT the +// value decides rather than one the declaration fixes: the bounded spellings +// `[..N]T` and `[Min..N]T`, and the unbounded `[]T` (docs/SPEC-TABLES.md §2.9, +// §3). The two write the same bytes, so every walk that asks "how many +// elements ride" asks this and not the spelling. +func (f *Field) CountedOnWire() bool { + return f != nil && (f.Array == ArrayCounted || f.Array == ArrayList) +}