From 12552e125d29a7e0cef1f73c30586f613d78dfa7 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 08:22:45 +0300 Subject: [PATCH 01/13] fix(ir): resolve named-const array dims (0083) + materialize literal slice args (0084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two silent-miscompile codegen fixes: 0083 — named-const array dimension. `TypeResolver.resolveCompound`'s array arm resolved the dimension with `if int_literal ... else 0`, so a named const (`N :: 16; [N]T`) hit the silent `else 0`: the array became 0-length / 0-byte and element access ran out of bounds (garbage for scalars, bus error for slice/pointer/struct elements). The arm now delegates the dimension to `inner.resolveArrayLen` (symmetric with `inner.resolveInner` for the element). The stateful `Lowering.resolveArrayLen` evaluates it as a compile-time integer across the comptime-constant / generic-value / module-global const tables and emits a diagnostic — no fabricated length — when it isn't one. 0084 — `.[...]` literal passed directly as a call arg. `lowerArrayLiteral` always yields an aggregate array value; the array→slice conversion is the caller's job. The local-bound var-decl path did it, but the call-arg coercion path had no array→slice arm, so `classify([N]T, []T)` returned `.none` and the raw array was passed where a slice was expected (callee read its {ptr,len} header off the wrong bytes → 0 / garbage / segfault). `classify` now returns a new `.array_to_slice` plan for same-element `[N]T → []T`, and `coerceToType` emits the existing `array_to_slice` op — identical to the local-bound path. Regressions (fail-before/pass-after demonstrated on the pre-fix compiler): examples/0140-types-named-const-array-dim.sx (s64 + string + struct elems) examples/0141-types-slice-literal-direct-call-arg.sx (string + []s64) Gate: zig build, zig build test, bash tests/run_examples.sh (387 passed). Issues 0083 and 0084 marked RESOLVED. --- examples/0140-types-named-const-array-dim.sx | 32 ++++++++++++ ...141-types-slice-literal-direct-call-arg.sx | 34 +++++++++++++ .../0140-types-named-const-array-dim.exit | 1 + .../0140-types-named-const-array-dim.stderr | 1 + .../0140-types-named-const-array-dim.stdout | 3 ++ ...1-types-slice-literal-direct-call-arg.exit | 1 + ...types-slice-literal-direct-call-arg.stderr | 1 + ...types-slice-literal-direct-call-arg.stdout | 4 ++ ...named-const-array-dimension-miscompiled.md | 42 ++++++++++++++++ ...ice-literal-direct-call-arg-miscompiled.md | 43 ++++++++++++++++ src/ir/conversions.zig | 15 ++++++ src/ir/lower.zig | 50 +++++++++++++++++++ src/ir/type_bridge.zig | 12 +++++ src/ir/type_resolver.test.zig | 6 +++ src/ir/type_resolver.zig | 7 ++- 15 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 examples/0140-types-named-const-array-dim.sx create mode 100644 examples/0141-types-slice-literal-direct-call-arg.sx create mode 100644 examples/expected/0140-types-named-const-array-dim.exit create mode 100644 examples/expected/0140-types-named-const-array-dim.stderr create mode 100644 examples/expected/0140-types-named-const-array-dim.stdout create mode 100644 examples/expected/0141-types-slice-literal-direct-call-arg.exit create mode 100644 examples/expected/0141-types-slice-literal-direct-call-arg.stderr create mode 100644 examples/expected/0141-types-slice-literal-direct-call-arg.stdout create mode 100644 issues/0083-named-const-array-dimension-miscompiled.md create mode 100644 issues/0084-slice-literal-direct-call-arg-miscompiled.md diff --git a/examples/0140-types-named-const-array-dim.sx b/examples/0140-types-named-const-array-dim.sx new file mode 100644 index 0000000..a17a519 --- /dev/null +++ b/examples/0140-types-named-const-array-dim.sx @@ -0,0 +1,32 @@ +// A fixed array whose dimension is a module-global named constant +// (`N :: 16; [N]T`) has the same layout as a literal-dimension array +// (`[16]T`): correct length and element stride for scalar, slice/pointer +// (string), and struct element types. +// Regression (issue 0083): a named-const dim resolved to length 0, giving a +// 0-byte alloca — scalar reads returned garbage and string/struct elements +// bus-errored. +#import "modules/std.sx"; + +N :: 4; + +P :: struct { x: s64; y: s64; } + +main :: () { + // Scalar elements: store then read back. + a : [N]s64 = ---; + a[0] = 7; + a[3] = 42; + print("scalar a0={} a3={}\n", a[0], a[3]); + + // Slice/pointer elements (string): used to bus-error. + s : [N]string = ---; + s[0] = "hi"; + s[1] = "yo"; + print("string s0={} s1={}\n", s[0], s[1]); + + // Struct elements. + ps : [N]P = ---; + ps[0] = P.{ x = 1, y = 2 }; + ps[2] = P.{ x = 5, y = 6 }; + print("struct p0x={} p0y={} p2x={}\n", ps[0].x, ps[0].y, ps[2].x); +} diff --git a/examples/0141-types-slice-literal-direct-call-arg.sx b/examples/0141-types-slice-literal-direct-call-arg.sx new file mode 100644 index 0000000..8351d6b --- /dev/null +++ b/examples/0141-types-slice-literal-direct-call-arg.sx @@ -0,0 +1,34 @@ +// A `.[...]` array/slice literal passed DIRECTLY as a call argument behaves +// identically to binding it to a typed local first: the literal is +// materialized into addressable storage and a {ptr,len} slice header is built +// over it, so the callee reads the element CONTENTS correctly. +// Regression (issue 0084): a direct literal arg passed the raw array value +// where a slice was expected, so the callee read its header off the wrong +// bytes and returned garbage (0). +#import "modules/std.sx"; + +count_nope :: (xs: []string) -> s64 { + n := 0; + i := 0; + while i < xs.len { if xs[i] == "nope" { n += 1; } i += 1; } + return n; +} + +sum :: (xs: []s64) -> s64 { + s := 0; + i := 0; + while i < xs.len { s += xs[i]; i += 1; } + return s; +} + +main :: () { + // string slice: direct literal vs local-bound — both see 2 "nope"s. + print("str direct={}\n", count_nope(.["a", "nope", "b", "nope"])); + local : []string = .["a", "nope", "b", "nope"]; + print("str local={}\n", count_nope(local)); + + // numeric slice: direct literal vs local-bound — both sum to 100. + print("num direct={}\n", sum(.[10, 20, 30, 40])); + nums : []s64 = .[10, 20, 30, 40]; + print("num local={}\n", sum(nums)); +} diff --git a/examples/expected/0140-types-named-const-array-dim.exit b/examples/expected/0140-types-named-const-array-dim.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0140-types-named-const-array-dim.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0140-types-named-const-array-dim.stderr b/examples/expected/0140-types-named-const-array-dim.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0140-types-named-const-array-dim.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0140-types-named-const-array-dim.stdout b/examples/expected/0140-types-named-const-array-dim.stdout new file mode 100644 index 0000000..9d6220b --- /dev/null +++ b/examples/expected/0140-types-named-const-array-dim.stdout @@ -0,0 +1,3 @@ +scalar a0=7 a3=42 +string s0=hi s1=yo +struct p0x=1 p0y=2 p2x=5 diff --git a/examples/expected/0141-types-slice-literal-direct-call-arg.exit b/examples/expected/0141-types-slice-literal-direct-call-arg.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0141-types-slice-literal-direct-call-arg.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0141-types-slice-literal-direct-call-arg.stderr b/examples/expected/0141-types-slice-literal-direct-call-arg.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0141-types-slice-literal-direct-call-arg.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0141-types-slice-literal-direct-call-arg.stdout b/examples/expected/0141-types-slice-literal-direct-call-arg.stdout new file mode 100644 index 0000000..d723da5 --- /dev/null +++ b/examples/expected/0141-types-slice-literal-direct-call-arg.stdout @@ -0,0 +1,4 @@ +str direct=2 +str local=2 +num direct=100 +num local=100 diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md new file mode 100644 index 0000000..1b75f26 --- /dev/null +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -0,0 +1,42 @@ +# 0083 — fixed array with a named-constant dimension is miscompiled + +> **RESOLVED.** Root cause: `TypeResolver.resolveCompound`'s array arm resolved +> the dimension with `if (length.data == .int_literal) ... else 0` — a named +> const (`N :: 16`) hit the silent `else 0`, so `[N]T` became a 0-length / 0-byte +> array and element access ran out of bounds (garbage for scalars, bus error for +> slice/pointer/struct elements). Fix: the array arm now delegates the dimension +> to `inner.resolveArrayLen` (symmetric with `inner.resolveInner` for the element +> type). The stateful `Lowering.resolveArrayLen` evaluates the dimension as a +> compile-time integer across the comptime-constant, generic-value, and +> module-global const tables, and emits a diagnostic (no fabricated length) when +> it isn't one. Files: `src/ir/type_resolver.zig`, `src/ir/lower.zig`, +> `src/ir/type_bridge.zig`. Regression: `examples/0140-types-named-const-array-dim.sx` +> (s64 + string + struct element types). + +## Symptom +A fixed array whose dimension is a module-global integer constant (`N :: 16; +a : [N]T`) miscompiles element access: reads/writes compute a wrong address. +With `s64` elements `a[0]` returns GARBAGE (silent); with slice/pointer element +types (`[N]string`) it Bus-errors. The identical program with a LITERAL dimension +(`a : [16]T`) is correct. Silent-miscompile class (cf. 0079–0082). + +## Reproduction +```sx +#import "modules/std.sx"; +N :: 16; +main :: () { a : [N]s64 = ---; a[0] = 7; print("a0={}\n", a[0]); } +``` +`./zig-out/bin/sx run` prints `a0=8472789232` (garbage); want `a0=7`. Replacing +`[N]` with `[16]` prints `7`. + +## Investigation prompt +A fixed-array TYPE whose dimension is a named const (`N :: 16; [N]T`) resolves to +a wrong element stride / array length in codegen — element address computation is +wrong (garbage for scalars, bad pointer for slice/pointer elements). Literal +dimensions are correct, so the defect is in resolving the array-type DIMENSION +from a constant expression (vs a literal) — the dim likely resolves to 0/unknown +or the element size is wrong. Look at array-type resolution where the length is a +const-expr (type lowering / sizeof / element-stride computation). Fix so a +named-const dimension yields the same layout as the literal. Verify with the +repro (expect 7) + a `[N]string`/`[N]struct` case (no bus error, correct reads), +and `zig build && zig build test && bash tests/run_examples.sh` green. diff --git a/issues/0084-slice-literal-direct-call-arg-miscompiled.md b/issues/0084-slice-literal-direct-call-arg-miscompiled.md new file mode 100644 index 0000000..0a6ec71 --- /dev/null +++ b/issues/0084-slice-literal-direct-call-arg-miscompiled.md @@ -0,0 +1,43 @@ +# 0084 — array/slice literal passed directly as a call argument miscompiles + +> **RESOLVED.** Root cause: `lowerArrayLiteral` always produces an aggregate +> ARRAY value; the array→slice conversion is the caller's job. The local-bound +> var-decl path did it (emits `array_to_slice`), but the call-argument coercion +> path (`coerceCallArgs` → `coerceToType` → `CoercionResolver.classify`) had no +> array→slice arm, so `classify([N]T, []T)` returned `.none` and the raw array +> value was passed where a slice was expected — the callee read its {ptr,len} +> header off the wrong bytes (returned 0 / garbage, segfaulted for `[]s64`). Fix: +> `classify` now returns a new `.array_to_slice` plan for `[N]T → []T` (same +> element type), and `coerceToType` emits the existing `array_to_slice` op, which +> materializes the array into addressable storage and builds the slice header — +> identical to the local-bound path. Files: `src/ir/conversions.zig`, +> `src/ir/lower.zig`. Regression: `examples/0141-types-slice-literal-direct-call-arg.sx` +> (string + numeric `[]s64`, direct vs local-bound). + +## Symptom +A `.[...]` array/slice literal passed DIRECTLY as a call argument yields a slice +whose element CONTENTS are not reliably readable in the callee (silent — reads +garbage, wrong results). Binding the same literal to a typed local first and +passing the local is correct. + +## Reproduction +```sx +#import "modules/std.sx"; +show :: (xs: []string) -> s64 { n:=0; i:=0; while i |lit| lit.value, + .identifier => |id| self.comptimeIntNamed(id.name), + .type_expr => |te| self.comptimeIntNamed(te.name), + else => null, + }; + } + + /// Resolve a name to a compile-time integer across the three const tables. + fn comptimeIntNamed(self: *Lowering, name: []const u8) ?i64 { + if (self.comptime_constants.get(name)) |cv| switch (cv) { + .int_val => |iv| return iv, + else => {}, + }; + if (self.comptime_value_bindings) |cvb| { + if (cvb.get(name)) |v| return v; + } + if (self.program_index.module_const_map.get(name)) |ci| { + if (ci.value.data == .int_literal) return ci.value.data.int_literal.value; + } + return null; + } + /// Resolve a type node, checking type_bindings first for generic type params. pub fn resolveTypeWithBindings(self: *Lowering, node: *const Node) TypeId { // Pack-index in a type position: `$[]` resolves to the @@ -13984,6 +14033,7 @@ pub const Lowering = struct { .ptr_int_bitcast => return self.builder.emit(.{ .bitcast = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), .narrow => return self.builder.emit(.{ .narrow = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), .widen => return self.builder.emit(.{ .widen = .{ .operand = val, .from = src_ty, .to = dst_ty } }, dst_ty), + .array_to_slice => return self.builder.emit(.{ .array_to_slice = .{ .operand = val } }, dst_ty), } } diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index 6145395..00711f2 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -28,6 +28,18 @@ const StatelessInner = struct { pub fn resolveInner(self: StatelessInner, node: *const Node) TypeId { return resolveAstType(node, self.table, self.alias_map); } + /// Fixed-array dimension at registration time (no bindings / const tables). + /// Only a literal dimension is knowable here; a named-const dimension + /// (`N :: 16; [N]T`) is resolved by the stateful caller + /// (`Lowering.resolveArrayLen`) before it ever reaches this binding-free + /// path — mirroring how `pack_index_type_expr` is handled stateful-first. + pub fn resolveArrayLen(self: StatelessInner, len_node: *const Node) u32 { + _ = self; + return switch (len_node.data) { + .int_literal => |lit| @intCast(lit.value), + else => 0, + }; + } }; // ── AST Node → TypeId ─────────────────────────────────────────────────── diff --git a/src/ir/type_resolver.test.zig b/src/ir/type_resolver.test.zig index e16562f..ad51f8f 100644 --- a/src/ir/type_resolver.test.zig +++ b/src/ir/type_resolver.test.zig @@ -23,6 +23,12 @@ const PrimInner = struct { else => .unresolved, }; } + pub fn resolveArrayLen(_: PrimInner, len_node: *const Node) u32 { + return switch (len_node.data) { + .int_literal => |lit| @intCast(lit.value), + else => 0, + }; + } }; test "TypeResolver.resolvePrimitive maps builtin keywords, null otherwise" { diff --git a/src/ir/type_resolver.zig b/src/ir/type_resolver.zig index 2cb7166..7a710d9 100644 --- a/src/ir/type_resolver.zig +++ b/src/ir/type_resolver.zig @@ -93,7 +93,12 @@ pub const TypeResolver = struct { .optional_type_expr => |ot| table.optionalOf(inner.resolveInner(ot.inner_type)), .array_type_expr => |at| blk: { const elem = inner.resolveInner(at.element_type); - const len: u32 = if (at.length.data == .int_literal) @intCast(at.length.data.int_literal.value) else 0; + // The dimension is delegated to `inner` exactly like the element + // type: a literal `[16]T` and a named-const `N :: 16; [N]T` must + // produce the same length. The stateful resolver consults the + // const tables; the binding-free one handles literal dims (issue + // 0083 — a 0 here gives a 0-byte array and OOB element access). + const len = inner.resolveArrayLen(at.length); break :blk table.arrayOf(elem, len); }, .function_type_expr => |ft| blk: { From 1f9f944ca18dd6f844fe8b555f091165d28f7238 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 09:06:08 +0300 Subject: [PATCH 02/13] fix(ir): exhaustive named-const array dims (0083) + nested slice-literal coercion (0085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the F0.4 fixes exhaustive across every resolution / nesting path. 0083 — named-const array dimension, stateless paths. Attempt 1 fixed the stateful resolver (direct local decls, struct fields, params, returns) but the binding-free registration-time resolver (`type_bridge`, used for type aliases `Arr :: [N]T` and inline union/enum field types) still resolved a named dim with a silent `else 0`, so `Arr :: [N]s64; a : Arr` and `union { a: [N]s64 }` were still miscompiled (garbage / bus error). Thread the module-global const table (`ProgramIndex.module_const_map`) into `type_bridge` alongside the alias map, so `StatelessInner.resolveArrayLen` resolves a named module-const dim to the same length everywhere. The remaining unresolvable case (a computed/comptime dim on the binding-free path, which the stateful path hard-errors) now bails LOUDLY instead of fabricating a 0 length. 0085 — nested slice-literal elements. `lowerArrayLiteral` lowered each element with the element type as target but appended the raw value. A nested `.[...]` element at a slice element type (`[][]s64`) still lowers to an aggregate array `[N]T`, so the outer aggregate held raw arrays where slice {ptr,len} headers were expected — indexing the inner slice read a garbage pointer and segfaulted. After lowering each element, coerce a same-element array to the slice element type via the existing `array_to_slice` op. The coercion recurses with the nesting, so `[][]T` and deeper materialize at every level — local-bound AND direct-call-argument forms. Regressions (fail-before/pass-after demonstrated on the pre-fix compiler): examples/0140-types-named-const-array-dim.sx — extended with type-alias, nested [N][M]T, and union-field named dims (s64 / string / struct elems) examples/0142-types-nested-slice-literal-elements.sx — [][]s64 + [][]string, local-bound vs direct-arg src/ir/type_bridge.test.zig — named-const dim resolves to literal length Gate: zig build, zig build test, bash tests/run_examples.sh (388 passed). Issues 0083 and 0085 marked RESOLVED. --- examples/0140-types-named-const-array-dim.sx | 47 +++++++- ...142-types-nested-slice-literal-elements.sx | 44 ++++++++ .../0140-types-named-const-array-dim.stdout | 4 + ...2-types-nested-slice-literal-elements.exit | 1 + ...types-nested-slice-literal-elements.stderr | 1 + ...types-nested-slice-literal-elements.stdout | 4 + ...named-const-array-dimension-miscompiled.md | 17 ++- ...sted-slice-literal-elements-not-coerced.md | 55 +++++++++ src/ir/calls.zig | 2 +- src/ir/lower.zig | 72 +++++++----- src/ir/protocols.zig | 10 +- src/ir/type_bridge.test.zig | 60 +++++++--- src/ir/type_bridge.zig | 104 +++++++++++------- 13 files changed, 329 insertions(+), 92 deletions(-) create mode 100644 examples/0142-types-nested-slice-literal-elements.sx create mode 100644 examples/expected/0142-types-nested-slice-literal-elements.exit create mode 100644 examples/expected/0142-types-nested-slice-literal-elements.stderr create mode 100644 examples/expected/0142-types-nested-slice-literal-elements.stdout create mode 100644 issues/0085-nested-slice-literal-elements-not-coerced.md diff --git a/examples/0140-types-named-const-array-dim.sx b/examples/0140-types-named-const-array-dim.sx index a17a519..caf79d0 100644 --- a/examples/0140-types-named-const-array-dim.sx +++ b/examples/0140-types-named-const-array-dim.sx @@ -1,32 +1,69 @@ // A fixed array whose dimension is a module-global named constant // (`N :: 16; [N]T`) has the same layout as a literal-dimension array // (`[16]T`): correct length and element stride for scalar, slice/pointer -// (string), and struct element types. +// (string), and struct element types — on EVERY type-resolution path: +// direct local decls, type aliases (`Arr :: [N]T`), nested fixed arrays +// (`[N][M]T`), and inline union fields. The named dim must resolve to the +// same length whether it flows through the stateful body-lowering resolver +// or the stateless registration-time resolver (type_bridge). // Regression (issue 0083): a named-const dim resolved to length 0, giving a // 0-byte alloca — scalar reads returned garbage and string/struct elements -// bus-errored. +// bus-errored. The alias and union-field paths went through the stateless +// resolver, which had no const table and silently fabricated a 0 length. #import "modules/std.sx"; N :: 4; +M :: 3; P :: struct { x: s64; y: s64; } +// Type aliases whose dimension is the named const N (stateless registration). +Arr :: [N]s64; +SArr :: [N]string; + +// Inline union field with a named-const dimension (stateless registration). +U :: union { a: [N]s64; tag: s64; } + main :: () { - // Scalar elements: store then read back. + // Scalar elements (direct local): store then read back. a : [N]s64 = ---; a[0] = 7; a[3] = 42; print("scalar a0={} a3={}\n", a[0], a[3]); - // Slice/pointer elements (string): used to bus-error. + // Slice/pointer elements (string, direct local): used to bus-error. s : [N]string = ---; s[0] = "hi"; s[1] = "yo"; print("string s0={} s1={}\n", s[0], s[1]); - // Struct elements. + // Struct elements (direct local). ps : [N]P = ---; ps[0] = P.{ x = 1, y = 2 }; ps[2] = P.{ x = 5, y = 6 }; print("struct p0x={} p0y={} p2x={}\n", ps[0].x, ps[0].y, ps[2].x); + + // Type-alias dimension (scalar): same layout as the direct `[N]s64`. + aa : Arr = ---; + aa[0] = 11; + aa[3] = 99; + print("alias a0={} a3={}\n", aa[0], aa[3]); + + // Type-alias dimension (string): no bus error, correct reads. + sa : SArr = ---; + sa[0] = "al"; + sa[2] = "ok"; + print("alias s0={} s2={}\n", sa[0], sa[2]); + + // Nested fixed array `[N][M]s64`: both dimensions are named consts. + grid : [N][M]s64 = ---; + grid[0][0] = 1; + grid[3][2] = 8; + print("nested g00={} g32={}\n", grid[0][0], grid[3][2]); + + // Inline union field with a named-const dimension. + u : U = ---; + u.a[0] = 70; + u.a[3] = 7; + print("union u0={} u3={}\n", u.a[0], u.a[3]); } diff --git a/examples/0142-types-nested-slice-literal-elements.sx b/examples/0142-types-nested-slice-literal-elements.sx new file mode 100644 index 0000000..7a1e088 --- /dev/null +++ b/examples/0142-types-nested-slice-literal-elements.sx @@ -0,0 +1,44 @@ +// A nested array/slice literal (`.[.[1, 2], .[3, 4]]`) at an expected slice-of- +// slices type (`[][]s64`) materializes each inner `[N]T` literal as a real `[]T` +// slice, so indexing the inner slice in the callee reads element contents +// correctly — for both the local-bound form and the direct-call-argument form. +// Regression (issue 0085): inner literals were appended as raw `[N]T` arrays +// under an element type of `[]T`, so the outer aggregate's elements were arrays +// where slice {ptr,len} headers were expected; indexing the inner slice read a +// garbage pointer and segfaulted. The per-element array->slice materialization +// recurses with the nesting, so every level coerces. +#import "modules/std.sx"; + +sum_nested :: (xss: [][]s64) -> s64 { + total := 0; + i := 0; + while i < xss.len { + j := 0; + while j < xss[i].len { total += xss[i][j]; j += 1; } + i += 1; + } + return total; +} + +count_x :: (xss: [][]string) -> s64 { + n := 0; + i := 0; + while i < xss.len { + j := 0; + while j < xss[i].len { if xss[i][j] == "x" { n += 1; } j += 1; } + i += 1; + } + return n; +} + +main :: () { + // numeric [][]s64 — local-bound vs direct-arg both sum to 10. + local : [][]s64 = .[.[1, 2], .[3, 4]]; + print("num local={}\n", sum_nested(local)); + print("num direct={}\n", sum_nested(.[.[1, 2], .[3, 4]])); + + // string [][]string — local-bound vs direct-arg both count 4 "x"s. + slocal : [][]string = .[.["x", "a"], .["b", "x"], .["x", "x"]]; + print("str local={}\n", count_x(slocal)); + print("str direct={}\n", count_x(.[.["x", "a"], .["b", "x"], .["x", "x"]])); +} diff --git a/examples/expected/0140-types-named-const-array-dim.stdout b/examples/expected/0140-types-named-const-array-dim.stdout index 9d6220b..579ec07 100644 --- a/examples/expected/0140-types-named-const-array-dim.stdout +++ b/examples/expected/0140-types-named-const-array-dim.stdout @@ -1,3 +1,7 @@ scalar a0=7 a3=42 string s0=hi s1=yo struct p0x=1 p0y=2 p2x=5 +alias a0=11 a3=99 +alias s0=al s2=ok +nested g00=1 g32=8 +union u0=70 u3=7 diff --git a/examples/expected/0142-types-nested-slice-literal-elements.exit b/examples/expected/0142-types-nested-slice-literal-elements.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0142-types-nested-slice-literal-elements.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0142-types-nested-slice-literal-elements.stderr b/examples/expected/0142-types-nested-slice-literal-elements.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0142-types-nested-slice-literal-elements.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0142-types-nested-slice-literal-elements.stdout b/examples/expected/0142-types-nested-slice-literal-elements.stdout new file mode 100644 index 0000000..8e79f9a --- /dev/null +++ b/examples/expected/0142-types-nested-slice-literal-elements.stdout @@ -0,0 +1,4 @@ +num local=10 +num direct=10 +str local=4 +str direct=4 diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index 1b75f26..73ab66e 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -9,9 +9,22 @@ > type). The stateful `Lowering.resolveArrayLen` evaluates the dimension as a > compile-time integer across the comptime-constant, generic-value, and > module-global const tables, and emits a diagnostic (no fabricated length) when -> it isn't one. Files: `src/ir/type_resolver.zig`, `src/ir/lower.zig`, +> it isn't one. +> +> **Exhaustive follow-up (attempt 2).** The first fix covered every *stateful* +> resolution path (direct local decls, struct fields, function params/returns), +> but the *stateless* registration-time resolver (`type_bridge`, used for type +> aliases `Arr :: [N]T` and inline union/enum field types) still resolved the +> named dim with a silent `else 0` — so `Arr :: [N]s64; a : Arr` and +> `union { a: [N]s64 }` were still miscompiled. Fix: the module-global const +> table (`ProgramIndex.module_const_map`) is now threaded into `type_bridge` +> alongside the alias map, so `StatelessInner.resolveArrayLen` resolves a named +> module-const dim to the same length everywhere. The remaining unresolvable case +> (a computed/comptime dimension on the binding-free path) bails LOUDLY instead of +> fabricating a 0 length. Files: `src/ir/type_resolver.zig`, `src/ir/lower.zig`, > `src/ir/type_bridge.zig`. Regression: `examples/0140-types-named-const-array-dim.sx` -> (s64 + string + struct element types). +> (direct + type-alias + nested `[N][M]T` + union-field dims, s64 / string / +> struct element types). ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/issues/0085-nested-slice-literal-elements-not-coerced.md b/issues/0085-nested-slice-literal-elements-not-coerced.md new file mode 100644 index 0000000..6a1a221 --- /dev/null +++ b/issues/0085-nested-slice-literal-elements-not-coerced.md @@ -0,0 +1,55 @@ +# 0085 — nested slice literal elements are stored as raw arrays + +> **RESOLVED.** Root cause: `Lowering.lowerArrayLiteral` lowered each element with +> the element type as `target_type` but appended the returned value directly. For +> a nested `.[...]` element whose expected element type is a slice (`[]T`), the +> inner literal still lowers to an aggregate ARRAY `[N]T` — so the outer aggregate +> (typed array-of-`[]T`) held raw arrays where slice {ptr,len} headers were +> expected; indexing the inner slice read a garbage pointer and segfaulted. Fix: +> after lowering each element, when the element type is a slice and the lowered +> value is a same-element array, coerce it via the existing `array_to_slice` op +> (materialize backing storage + build the header) — identical to the whole- +> literal coercion the var-decl / call-arg paths already run. The coercion +> recurses with the nesting, so `[][]T` and deeper materialize at every level. +> Files: `src/ir/lower.zig` (`lowerArrayLiteral`). Regression: +> `examples/0142-types-nested-slice-literal-elements.sx` (`[][]s64` + `[][]string`, +> local-bound AND direct-call-argument forms). + +## Symptom +Nested array/slice literals such as `.[.[1, 2], .[3, 4]]` miscompile when the +expected element type is a slice (`[][]s64`). Observed: both the local-bound and +direct-call forms segfault while indexing the inner slice. Expected: both forms +materialize each inner `[N]T` literal as a `[]T` slice and print the same value. + +## Reproduction +```sx +#import "modules/std.sx"; + +sum_nested :: (xss: [][]s64) -> s64 { + return xss[0][1] + xss[1][0]; +} + +main :: () { + local : [][]s64 = .[.[1, 2], .[3, 4]]; + print("local={}\n", sum_nested(local)); + print("direct={}\n", sum_nested(.[.[1, 2], .[3, 4]])); +} +``` +Observed on `flow/sx-foundation/F0.4`: segfault at address `0x9` before either +line prints. Expected output: +```text +local=5 +direct=5 +``` + +## Investigation prompt +Fix nested slice literal materialization. The likely area is +`src/ir/lower.zig` in `Lowering.lowerArrayLiteral`: the outer literal can know +its expected element type is `[]T`, and the loop sets `self.target_type = +elem_ty` while lowering each inner literal, but it appends the returned value +directly. For an inner `.[...]`, that returned value is still an array aggregate +`[N]T`, not the target `[]T` slice. Add per-element coercion/materialization +after lowering each element, using the element source type and expected +`elem_ty` (the existing `array_to_slice` coercion should be reused). Verify the +repro prints `local=5` and `direct=5`, then run `zig build && zig build test && +bash tests/run_examples.sh`. diff --git a/src/ir/calls.zig b/src/ir/calls.zig index 25302b0..1af5687 100644 --- a/src/ir/calls.zig +++ b/src/ir/calls.zig @@ -264,7 +264,7 @@ pub const CallResolver = struct { if (method_fd.body.data == .compiler_expr) { return .{ .kind = .struct_method, - .return_type = if (method_fd.return_type) |rt| type_bridge.resolveAstType(rt, &self.l.module.types, &self.l.program_index.type_alias_map) else .void, + .return_type = if (method_fd.return_type) |rt| type_bridge.resolveAstType(rt, &self.l.module.types, &self.l.program_index.type_alias_map, &self.l.program_index.module_const_map) else .void, .target = .{ .named = qualified }, .prepends_receiver = true, .expands_defaults = defaultsFor(method_fd, c.args.len + 1), diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 1558d7f..5045547 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -560,9 +560,9 @@ pub const Lowering = struct { } else if (cd.value.data == .struct_decl) { self.registerStructDecl(&cd.value.data.struct_decl); } else if (cd.value.data == .enum_decl) { - _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); } else if (cd.value.data == .union_decl) { - _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); } else if (cd.value.data == .comptime_expr) { self.lowerComptimeGlobal(cd.name, cd.value.data.comptime_expr.expr, cd.type_annotation); } @@ -574,10 +574,10 @@ pub const Lowering = struct { self.registerStructDecl(&sd); }, .enum_decl => { - _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); }, .union_decl => { - _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); }, .error_set_decl => { self.registerErrorSetDecl(decl); @@ -675,10 +675,10 @@ pub const Lowering = struct { self.registerStructDecl(&cd.value.data.struct_decl); } else if (cd.value.data == .enum_decl) { // Register enum/tagged-union types in the type table - _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); } else if (cd.value.data == .union_decl) { // Register plain union types in the type table - _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); } else if (cd.value.data == .type_expr or cd.value.data == .pointer_type_expr or cd.value.data == .many_pointer_type_expr or @@ -688,7 +688,7 @@ pub const Lowering = struct { cd.value.data == .function_type_expr) { // Type alias: MyFloat :: f64; Ptr :: *u8; Cb :: (s32) -> s32; - const target_ty = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map); + const target_ty = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); self.program_index.type_alias_map.put(cd.name, target_ty) catch {}; } else if (cd.value.data == .identifier) { // Identifier-RHS alias: MyAlias :: MyInt; WideAlias :: Wide; @@ -771,7 +771,7 @@ pub const Lowering = struct { // resolve via type_bridge and register the result // under the alias name so `Vec4` in expression // position can `const_type()`. - const result_ty = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map); + const result_ty = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); if (result_ty != .void and result_ty != .unresolved) { self.program_index.type_alias_map.put(cd.name, result_ty) catch {}; } @@ -806,11 +806,11 @@ pub const Lowering = struct { }, .enum_decl => { // Register enum/tagged-union types in the type table - _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); }, .union_decl => { // Register plain union types in the type table - _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(decl, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); }, .error_set_decl => { self.registerErrorSetDecl(decl); @@ -1813,7 +1813,7 @@ pub const Lowering = struct { // Block-local type declarations .struct_decl => |sd| self.registerStructDecl(&sd), .enum_decl, .union_decl => { - _ = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); }, .error_set_decl => self.registerErrorSetDecl(node), .ufcs_alias => |ua| { @@ -1972,7 +1972,7 @@ pub const Lowering = struct { return; } if (cd.value.data == .enum_decl or cd.value.data == .union_decl) { - _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); return; } @@ -2912,7 +2912,7 @@ pub const Lowering = struct { // `t : Type = f64;` store a real TypeId; lets // `t == f64` icmp at runtime against the same TypeId. if (self.isKnownTypeName(te.name)) { - const ty = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map); + const ty = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); break :blk self.builder.constType(ty); } break :blk self.emitError(te.name, node.span); @@ -5357,8 +5357,26 @@ pub const Lowering = struct { for (al.elements) |elem| { const old_tt = self.target_type; self.target_type = elem_ty; - const val = self.lowerExpr(elem); + var val = self.lowerExpr(elem); self.target_type = old_tt; + // A nested `.[...]` element at a slice element type lowers to an + // aggregate array `[N]U` (lowerArrayLiteral always yields an array + // value); materialize it into a `[]U` slice so the element is a real + // {ptr,len} header rather than a raw array the callee would read its + // header off of (issue 0085). This per-element coercion recurses with + // the literal nesting, so `[][]T` and deeper coerce at every level. + if (!elem_ty.isBuiltin()) { + const ei = self.module.types.get(elem_ty); + if (ei == .slice) { + const val_ty = self.builder.getRefType(val); + if (!val_ty.isBuiltin()) { + const vi = self.module.types.get(val_ty); + if (vi == .array and vi.array.element == ei.slice.element) { + val = self.coerceToType(val, val_ty, elem_ty); + } + } + } + } elems.append(self.alloc, val) catch unreachable; } @@ -5401,7 +5419,7 @@ pub const Lowering = struct { const name_id = self.module.types.internString(id.name); return self.module.types.findByName(name_id) orelse .unresolved; }, - .type_expr => return type_bridge.resolveAstType(te, &self.module.types, &self.program_index.type_alias_map), + .type_expr => return type_bridge.resolveAstType(te, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map), .field_access => |fa| { // Module.Type — try to resolve the field as a type name const name_id = self.module.types.internString(fa.field); @@ -6875,7 +6893,7 @@ pub const Lowering = struct { // Check for #compiler free functions if (self.program_index.fn_ast_map.get(func_name)) |fd_check| { if (fd_check.body.data == .compiler_expr) { - const ret_ty = if (fd_check.return_type) |rt| type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map) else TypeId.void; + const ret_ty = if (fd_check.return_type) |rt| type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map) else TypeId.void; return self.builder.compilerCall(func_name, args.items, ret_ty); } } @@ -7230,7 +7248,7 @@ pub const Lowering = struct { if (self.program_index.fn_ast_map.get(qualified)) |method_fd| { if (method_fd.body.data == .compiler_expr) { const ret_ty = if (method_fd.return_type) |rt| - type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map) + type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map) else .void; return self.builder.compilerCall(qualified, method_args.items, ret_ty); @@ -7668,7 +7686,7 @@ pub const Lowering = struct { const ret_ty = blk: { if (lam.return_type) |rt| { - break :blk type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map); + break :blk type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); } // Use target closure return type if available — but only when it's // a resolved type. An `.unresolved` ret comes from an unbound @@ -8238,7 +8256,7 @@ pub const Lowering = struct { } fn resolveReturnType2(self: *Lowering, rt: ?*const Node) TypeId { - if (rt) |r| return type_bridge.resolveAstType(r, &self.module.types, &self.program_index.type_alias_map); + if (rt) |r| return type_bridge.resolveAstType(r, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); return .void; } @@ -9278,8 +9296,8 @@ pub const Lowering = struct { const ret_ty: TypeId = blk: { if (fd.return_type) |rt| { if (rt.data == .type_expr) { - if (type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map) != .unresolved) { - break :blk type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map); + if (type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map) != .unresolved) { + break :blk type_bridge.resolveAstType(rt, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); } } } @@ -10551,7 +10569,7 @@ pub const Lowering = struct { return .unresolved; } } - return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map); + return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); } pub fn resolveTypeArg(self: *Lowering, node: *const Node) TypeId { @@ -10612,7 +10630,7 @@ pub const Lowering = struct { }, .type_expr => |te| { if (self.program_index.type_alias_map.get(te.name)) |alias_ty| return alias_ty; - return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map); + return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); }, .call => |cl| { // `type_of(x)` resolves to `inferExprType(x)` at lower @@ -10637,7 +10655,7 @@ pub const Lowering = struct { .slice_type_expr, .optional_type_expr, .function_type_expr, - => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map), + => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map), else => return .unresolved, } } @@ -11764,7 +11782,7 @@ pub const Lowering = struct { // literal (`(s32, s32)`); validate its elements are types and reject // non-type elements loudly (issue 0067). .tuple_literal => return self.resolveTupleLiteralTypeArg(node), - else => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map), + else => return type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map), } } @@ -12195,7 +12213,7 @@ pub const Lowering = struct { } return; } - _ = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map); + _ = type_bridge.resolveAstType(node, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); } fn registerStructDecl(self: *Lowering, sd: *const ast.StructDecl) void { @@ -12341,7 +12359,7 @@ pub const Lowering = struct { if (const_node.data == .const_decl) { const cd = const_node.data.const_decl; const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sd.name, cd.name }) catch continue; - const ty: ?TypeId = if (cd.type_annotation) |ta| type_bridge.resolveAstType(ta, table, &self.program_index.type_alias_map) else null; + const ty: ?TypeId = if (cd.type_annotation) |ta| type_bridge.resolveAstType(ta, table, &self.program_index.type_alias_map, &self.program_index.module_const_map) else null; self.struct_const_map.put(qualified, .{ .value = cd.value, .ty = ty }) catch {}; } } diff --git a/src/ir/protocols.zig b/src/ir/protocols.zig index d85cc1d..c5f719c 100644 --- a/src/ir/protocols.zig +++ b/src/ir/protocols.zig @@ -296,7 +296,7 @@ pub const ProtocolResolver = struct { if (p.data == .type_expr and std.mem.eql(u8, p.data.type_expr.name, "Self")) { break :blk void_ptr_ty; } - break :blk type_bridge.resolveAstType(p, table, &self.l.program_index.type_alias_map); + break :blk type_bridge.resolveAstType(p, table, &self.l.program_index.type_alias_map, &self.l.program_index.module_const_map); }; ptypes.append(self.l.alloc, pty) catch unreachable; } @@ -306,7 +306,7 @@ pub const ProtocolResolver = struct { ret_is_self = true; break :blk void_ptr_ty; } - break :blk type_bridge.resolveAstType(rt, table, &self.l.program_index.type_alias_map); + break :blk type_bridge.resolveAstType(rt, table, &self.l.program_index.type_alias_map, &self.l.program_index.module_const_map); } else .void; method_infos.append(self.l.alloc, .{ .name = method.name, @@ -393,7 +393,7 @@ pub const ProtocolResolver = struct { // Resolve the protocol's type-arg list to concrete TypeIds. var arg_tys = std.ArrayList(TypeId).empty; for (ib.protocol_type_args) |arg_node| { - const t = type_bridge.resolveAstType(arg_node, table, &self.l.program_index.type_alias_map); + const t = type_bridge.resolveAstType(arg_node, table, &self.l.program_index.type_alias_map, &self.l.program_index.module_const_map); arg_tys.append(self.l.alloc, t) catch return; } @@ -401,9 +401,9 @@ pub const ProtocolResolver = struct { // parameterised impls (back-compat `target_type` string is kept for // simple cases but the canonical form is the TypeExpr). const src_ty: TypeId = if (ib.target_type_expr) |te| - type_bridge.resolveAstType(te, table, &self.l.program_index.type_alias_map) + type_bridge.resolveAstType(te, table, &self.l.program_index.type_alias_map, &self.l.program_index.module_const_map) else if (ib.target_type.len > 0) - type_bridge.resolveAstType(&.{ .span = decl.span, .data = .{ .type_expr = .{ .name = ib.target_type } } }, table, &self.l.program_index.type_alias_map) + type_bridge.resolveAstType(&.{ .span = decl.span, .data = .{ .type_expr = .{ .name = ib.target_type } } }, table, &self.l.program_index.type_alias_map, &self.l.program_index.module_const_map) else return; diff --git a/src/ir/type_bridge.test.zig b/src/ir/type_bridge.test.zig index 571eea4..c2cb411 100644 --- a/src/ir/type_bridge.test.zig +++ b/src/ir/type_bridge.test.zig @@ -3,6 +3,8 @@ const std = @import("std"); const types = @import("types.zig"); const type_bridge = @import("type_bridge.zig"); const ast = @import("../ast.zig"); +const program_index_mod = @import("program_index.zig"); +const ModuleConstInfo = program_index_mod.ModuleConstInfo; const Node = ast.Node; const TypeId = types.TypeId; @@ -18,7 +20,7 @@ test "resolveAstType: primitive type_expr" { defer alloc.destroy(node); node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "f64" } } }; - try std.testing.expectEqual(TypeId.f64, type_bridge.resolveAstType(node, &table, null)); + try std.testing.expectEqual(TypeId.f64, type_bridge.resolveAstType(node, &table, null, null)); } test "resolveAstType: pointer type" { @@ -34,7 +36,7 @@ test "resolveAstType: pointer type" { defer alloc.destroy(node); node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .pointer_type_expr = .{ .pointee_type = inner } } }; - const id = type_bridge.resolveAstType(node, &table, null); + const id = type_bridge.resolveAstType(node, &table, null, null); try std.testing.expectEqual(TypeInfo{ .pointer = .{ .pointee = .s32 } }, table.get(id)); } @@ -55,7 +57,7 @@ test "resolveAstType: optional slice" { defer alloc.destroy(opt); opt.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .optional_type_expr = .{ .inner_type = slice } } }; - const id = type_bridge.resolveAstType(opt, &table, null); + const id = type_bridge.resolveAstType(opt, &table, null, null); const info = table.get(id); switch (info) { .optional => |o| { @@ -71,7 +73,7 @@ test "resolveAstType: null surfaces as .unresolved (no silent s64 default)" { var table = TypeTable.init(alloc); defer table.deinit(); - try std.testing.expectEqual(TypeId.unresolved, type_bridge.resolveAstType(null, &table, null)); + try std.testing.expectEqual(TypeId.unresolved, type_bridge.resolveAstType(null, &table, null, null)); } test "resolveAstType: threaded alias_map resolves named alias" { @@ -85,7 +87,7 @@ test "resolveAstType: threaded alias_map resolves named alias" { defer alloc.destroy(sh_node); sh_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "ShaderHandle" } } }; - const empty_stub = type_bridge.resolveAstType(sh_node, &table, null); + const empty_stub = type_bridge.resolveAstType(sh_node, &table, null, null); const empty_info = table.get(empty_stub); try std.testing.expectEqual(@as(std.meta.Tag(TypeInfo), .@"struct"), std.meta.activeTag(empty_info)); try std.testing.expectEqual(@as(usize, 0), empty_info.@"struct".fields.len); @@ -102,7 +104,7 @@ test "resolveAstType: threaded alias_map resolves named alias" { defer alloc.destroy(opaque_node); opaque_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "Opaque" } } }; try aliases.put("Opaque", .u64); - try std.testing.expectEqual(TypeId.u64, type_bridge.resolveAstType(opaque_node, &table, &aliases)); + try std.testing.expectEqual(TypeId.u64, type_bridge.resolveAstType(opaque_node, &table, &aliases, null)); // Compound forms (`*Opaque`, `[]Opaque`, `?Opaque`) route through recursive // helpers that thread the same alias_map at every step. @@ -112,10 +114,42 @@ test "resolveAstType: threaded alias_map resolves named alias" { const ptr_node = try alloc.create(Node); defer alloc.destroy(ptr_node); ptr_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .pointer_type_expr = .{ .pointee_type = opaque_inner } } }; - const ptr_id = type_bridge.resolveAstType(ptr_node, &table, &aliases); + const ptr_id = type_bridge.resolveAstType(ptr_node, &table, &aliases, null); try std.testing.expectEqual(TypeInfo{ .pointer = .{ .pointee = .u64 } }, table.get(ptr_id)); } +test "resolveAstType: named-const array dimension resolves to the same length as a literal (issue 0083)" { + const alloc = std.testing.allocator; + var table = TypeTable.init(alloc); + defer table.deinit(); + + // `N :: 4` in the module-const table, value backed by an int-literal node. + const n_val = try alloc.create(Node); + defer alloc.destroy(n_val); + n_val.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .int_literal = .{ .value = 4 } } }; + var consts = std.StringHashMap(ModuleConstInfo).init(alloc); + defer consts.deinit(); + try consts.put("N", .{ .value = n_val, .ty = .s64 }); + + // `[N]s64` — dimension is the named const `N`, not a literal. + const elem = try alloc.create(Node); + defer alloc.destroy(elem); + elem.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .type_expr = .{ .name = "s64" } } }; + const len_node = try alloc.create(Node); + defer alloc.destroy(len_node); + len_node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = "N" } } }; + const arr = try alloc.create(Node); + defer alloc.destroy(arr); + arr.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .array_type_expr = .{ .length = len_node, .element_type = elem } } }; + + // With the const table threaded, `[N]s64` lays out identically to `[4]s64`. + const id = type_bridge.resolveAstType(arr, &table, null, &consts); + const info = table.get(id); + try std.testing.expect(info == .array); + try std.testing.expectEqual(TypeId.s64, info.array.element); + try std.testing.expectEqual(@as(u32, 4), info.array.length); +} + test "resolveAstType: error_set_decl registers an error-set type + interns tags" { const alloc = std.testing.allocator; var table = TypeTable.init(alloc); @@ -129,7 +163,7 @@ test "resolveAstType: error_set_decl registers an error-set type + interns tags" .tag_names = &tag_names, } } }; - const id = type_bridge.resolveAstType(node, &table, null); + const id = type_bridge.resolveAstType(node, &table, null, null); const info = table.get(id); try std.testing.expect(info == .error_set); try std.testing.expectEqualStrings("ParseErr", table.getString(info.error_set.name)); @@ -137,7 +171,7 @@ test "resolveAstType: error_set_decl registers an error-set type + interns tags" // Tags were interned into the global pool (round-trip a name through it). try std.testing.expectEqualStrings("BadDigit", table.getTagName(table.internTag("BadDigit"))); // Re-resolving the same decl dedups to the same TypeId. - try std.testing.expectEqual(id, type_bridge.resolveAstType(node, &table, null)); + try std.testing.expectEqual(id, type_bridge.resolveAstType(node, &table, null, null)); } // ── ERR E1.2 — failable-signature error channel resolution ── @@ -154,7 +188,7 @@ test "resolveAstType: `!Named` resolves to the declared error set" { const node = try alloc.create(Node); defer alloc.destroy(node); node.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .error_type_expr = .{ .name = "ParseErr" } } }; - try std.testing.expectEqual(set, type_bridge.resolveAstType(node, &table, null)); + try std.testing.expectEqual(set, type_bridge.resolveAstType(node, &table, null, null)); } test "resolveAstType: bare `!` resolves to a shared inferred placeholder set" { @@ -169,8 +203,8 @@ test "resolveAstType: bare `!` resolves to a shared inferred placeholder set" { defer alloc.destroy(b); b.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .error_type_expr = .{ .name = null } } }; - const ia = type_bridge.resolveAstType(a, &table, null); - const ib = type_bridge.resolveAstType(b, &table, null); + const ia = type_bridge.resolveAstType(a, &table, null, null); + const ib = type_bridge.resolveAstType(b, &table, null, null); try std.testing.expect(table.get(ia) == .error_set); try std.testing.expectEqualStrings("!", table.getString(table.get(ia).error_set.name)); try std.testing.expectEqual(@as(usize, 0), table.get(ia).error_set.tags.len); // empty until E1.4 SCC @@ -198,7 +232,7 @@ test "resolveAstType: `(s32, !Named)` result list is a tuple ending in the error defer alloc.destroy(tuple); tuple.* = .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .tuple_type_expr = .{ .field_types = &fields, .field_names = null } } }; - const id = type_bridge.resolveAstType(tuple, &table, null); + const id = type_bridge.resolveAstType(tuple, &table, null, null); const info = table.get(id); try std.testing.expect(info == .tuple); try std.testing.expectEqual(@as(usize, 2), info.tuple.fields.len); diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index 00711f2..fc05de9 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -8,6 +8,8 @@ const TypeInfo = ir_types.TypeInfo; const TypeTable = ir_types.TypeTable; const StringId = ir_types.StringId; const type_resolver = @import("type_resolver.zig"); +const program_index_mod = @import("program_index.zig"); +const ModuleConstInfo = program_index_mod.ModuleConstInfo; /// The single-source type-alias table (`ProgramIndex.type_alias_map`), threaded /// explicitly through every name-resolving entry point so a bare name like @@ -17,6 +19,15 @@ const type_resolver = @import("type_resolver.zig"); /// `null` for contexts that never see aliases, e.g. unit tests). pub const AliasMap = ?*const std.StringHashMap(TypeId); +/// The module-global constant table (`ProgramIndex.module_const_map`), threaded +/// alongside the alias map so a named-const array dimension (`N :: 16; [N]T`) +/// resolves to the same length as a literal dimension on EVERY registration-time +/// path — type aliases (`Arr :: [N]T`), inline union/enum field types — not just +/// the stateful body-lowering path. Without it the stateless dim resolver had no +/// way to evaluate a named const and silently fabricated a 0 length (issue 0083). +/// `null` for contexts with no const table (e.g. unit tests). +pub const ConstMap = ?*const std.StringHashMap(ModuleConstInfo); + /// Binding-free element-recursion adapter for `TypeResolver.resolveCompound`: /// nested element types resolve through `type_bridge.resolveAstType` (the /// registration-time path — no generic/pack bindings). Lets type_bridge reuse @@ -25,20 +36,35 @@ pub const AliasMap = ?*const std.StringHashMap(TypeId); const StatelessInner = struct { table: *TypeTable, alias_map: AliasMap, + consts: ConstMap, pub fn resolveInner(self: StatelessInner, node: *const Node) TypeId { - return resolveAstType(node, self.table, self.alias_map); + return resolveAstType(node, self.table, self.alias_map, self.consts); } - /// Fixed-array dimension at registration time (no bindings / const tables). - /// Only a literal dimension is knowable here; a named-const dimension - /// (`N :: 16; [N]T`) is resolved by the stateful caller - /// (`Lowering.resolveArrayLen`) before it ever reaches this binding-free - /// path — mirroring how `pack_index_type_expr` is handled stateful-first. + /// Fixed-array dimension at registration time: a literal `[16]T`, or a + /// named module-global const `N :: 16; [N]T` looked up in the const table. + /// Both yield the SAME length — registration-time paths (aliases, inline + /// union/enum fields) must lay out a named-const dim identically to a literal + /// (issue 0083). A dimension that is neither is not resolvable on this + /// binding-free path (it would be a computed/comptime expression, which the + /// stateful body-lowering path diagnoses as a hard error at the storage + /// site); bail LOUDLY rather than fabricating a 0 length that silently gives a + /// 0-byte array and out-of-bounds element access. pub fn resolveArrayLen(self: StatelessInner, len_node: *const Node) u32 { - _ = self; - return switch (len_node.data) { - .int_literal => |lit| @intCast(lit.value), - else => 0, - }; + switch (len_node.data) { + .int_literal => |lit| return @intCast(lit.value), + .identifier => |id| if (self.namedConstLen(id.name)) |n| return n, + .type_expr => |te| if (self.namedConstLen(te.name)) |n| return n, + else => {}, + } + std.debug.print("type_bridge: array dimension is not a literal or named integer constant — cannot resolve length at registration time (computed/comptime dimensions are unsupported here)\n", .{}); + return 0; + } + /// A name that resolves to a module-global integer constant → its value. + fn namedConstLen(self: StatelessInner, name: []const u8) ?u32 { + const consts = self.consts orelse return null; + const ci = consts.get(name) orelse return null; + if (ci.value.data == .int_literal) return @intCast(ci.value.data.int_literal.value); + return null; } }; @@ -46,14 +72,14 @@ const StatelessInner = struct { // Resolve an AST type node into an IR TypeId. Used during lowering when // we only have the parsed AST (no codegen type registry). -pub fn resolveAstType(node: ?*const Node, table: *TypeTable, alias_map: AliasMap) TypeId { +pub fn resolveAstType(node: ?*const Node, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId { // A null node means a caller reached type resolution without a type node. // Every current caller either passes a non-optional node or handles the // "no type" case itself (returning `.void`), so this is a caller bug — and // `.s64` here would silently fabricate an 8-byte int. Surface it via the // `.unresolved` sentinel (trips the sizeOf/toLLVMType panic at codegen). const n = node orelse return .unresolved; - const si = StatelessInner{ .table = table, .alias_map = alias_map }; + const si = StatelessInner{ .table = table, .alias_map = alias_map, .consts = consts }; return switch (n.data) { .type_expr => |te| resolveTypeName(te.name, table, alias_map), .identifier => |id| resolveTypeName(id.name, table, alias_map), @@ -76,8 +102,8 @@ pub fn resolveAstType(node: ?*const Node, table: *TypeTable, alias_map: AliasMap // `Closure(..p)` field type at registration time). These tiny fallbacks // are the only stateless-specific shape code left; the stateful expand // lives in PackResolver. - .closure_type_expr => |ct| type_resolver.TypeResolver.resolveCompound(table, n, si) orelse resolveClosurePackShape(&ct, table, alias_map), - .tuple_type_expr => |tt| type_resolver.TypeResolver.resolveCompound(table, n, si) orelse resolveTupleSpreadShape(&tt, table, alias_map), + .closure_type_expr => |ct| type_resolver.TypeResolver.resolveCompound(table, n, si) orelse resolveClosurePackShape(&ct, table, alias_map, consts), + .tuple_type_expr => |tt| type_resolver.TypeResolver.resolveCompound(table, n, si) orelse resolveTupleSpreadShape(&tt, table, alias_map, consts), .pack_index_type_expr => { // Pack-index `$args[N]` in a type position must be resolved // against an active pack binding — `type_bridge` has no access @@ -90,8 +116,8 @@ pub fn resolveAstType(node: ?*const Node, table: *TypeTable, alias_map: AliasMap std.debug.print("type_bridge: pack-index type expression encountered outside a pack-aware context — returning .unresolved\n", .{}); return .unresolved; }, - .tuple_literal => |tl| resolveTupleLiteralAsType(&tl, table, alias_map), - .parameterized_type_expr => |pt| resolveParameterizedType(&pt, table, alias_map), + .tuple_literal => |tl| resolveTupleLiteralAsType(&tl, table, alias_map, consts), + .parameterized_type_expr => |pt| resolveParameterizedType(&pt, table, alias_map, consts), // An unannotated param. Its type must be resolved from context // (contextual closure typing, generic binding, or pack substitution) // *before* reaching here; if it doesn't, returning a plausible `.s64` @@ -101,9 +127,9 @@ pub fn resolveAstType(node: ?*const Node, table: *TypeTable, alias_map: AliasMap // turns it into a real diagnostic. .inferred_type => .unresolved, // Inline type declarations (used as field types) - .enum_decl => |ed| resolveInlineEnum(&ed, table, alias_map), - .struct_decl => |sd| resolveInlineStruct(&sd, table, alias_map), - .union_decl => |ud| resolveInlineUnion(&ud, table, alias_map), + .enum_decl => |ed| resolveInlineEnum(&ed, table, alias_map, consts), + .struct_decl => |sd| resolveInlineStruct(&sd, table, alias_map, consts), + .union_decl => |ud| resolveInlineUnion(&ud, table, alias_map, consts), .error_set_decl => |esd| resolveInlineErrorSet(&esd, table), .error_type_expr => |ete| resolveErrorType(&ete, table, alias_map), else => { @@ -137,13 +163,13 @@ pub const resolveTypePrimitive = type_resolver.TypeResolver.resolvePrimitive; /// null). type_bridge can't expand the pack (no state), so it preserves the /// pack SHAPE — a `closureTypePack` whose prefix is the fixed params. The /// stateful expand lives in `PackResolver.resolveClosureTypeWithBindings`. -fn resolveClosurePackShape(ct: *const ast.ClosureTypeExpr, table: *TypeTable, alias_map: AliasMap) TypeId { +fn resolveClosurePackShape(ct: *const ast.ClosureTypeExpr, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId { const alloc = table.alloc; var param_ids = std.ArrayList(TypeId).empty; for (ct.param_types) |pt| { - param_ids.append(alloc, resolveAstType(pt, table, alias_map)) catch unreachable; + param_ids.append(alloc, resolveAstType(pt, table, alias_map, consts)) catch unreachable; } - const ret_id = if (ct.return_type) |rt| resolveAstType(rt, table, alias_map) else TypeId.void; + const ret_id = if (ct.return_type) |rt| resolveAstType(rt, table, alias_map, consts) else TypeId.void; return table.closureTypePack(param_ids.items, ret_id, @intCast(param_ids.items.len)); } @@ -152,11 +178,11 @@ fn resolveClosurePackShape(ct: *const ast.ClosureTypeExpr, table: *TypeTable, al /// each field resolves individually (a spread field is not a type → resolves to /// `.unresolved`). The stateful expand lives in /// `PackResolver.resolveTupleTypeWithBindings`. -fn resolveTupleSpreadShape(tt: *const ast.TupleTypeExpr, table: *TypeTable, alias_map: AliasMap) TypeId { +fn resolveTupleSpreadShape(tt: *const ast.TupleTypeExpr, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId { const alloc = table.alloc; var field_ids = std.ArrayList(TypeId).empty; for (tt.field_types) |ft| { - field_ids.append(alloc, resolveAstType(ft, table, alias_map)) catch unreachable; + field_ids.append(alloc, resolveAstType(ft, table, alias_map, consts)) catch unreachable; } var name_ids: ?[]const StringId = null; if (tt.field_names) |names| { @@ -182,14 +208,14 @@ fn resolveTupleSpreadShape(tt: *const ast.TupleTypeExpr, table: *TypeTable, alia // here, so the valid path below builds the tuple and the invalid path never // reaches it from lowering. The sentinel is the backstop for any other // (binding-free) caller. -fn resolveTupleLiteralAsType(tl: *const ast.TupleLiteral, table: *TypeTable, alias_map: AliasMap) TypeId { +fn resolveTupleLiteralAsType(tl: *const ast.TupleLiteral, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId { const alloc = table.alloc; var field_ids = std.ArrayList(TypeId).empty; var name_ids_list = std.ArrayList(StringId).empty; var any_named = false; for (tl.elements) |el| { if (!isTypeShapedAstNode(el.value, table)) return .unresolved; - field_ids.append(alloc, resolveAstType(el.value, table, alias_map)) catch unreachable; + field_ids.append(alloc, resolveAstType(el.value, table, alias_map, consts)) catch unreachable; if (el.name) |n| { any_named = true; name_ids_list.append(alloc, table.internString(n)) catch unreachable; @@ -233,7 +259,7 @@ pub fn isTypeShapedAstNode(node: *const Node, table: *TypeTable) bool { }; } -fn resolveParameterizedType(pt: *const ast.ParameterizedTypeExpr, table: *TypeTable, alias_map: AliasMap) TypeId { +fn resolveParameterizedType(pt: *const ast.ParameterizedTypeExpr, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId { // Strip module prefix (e.g. "std.Vector" → "Vector") const base_name = if (std.mem.lastIndexOfScalar(u8, pt.name, '.')) |dot| pt.name[dot + 1 ..] else pt.name; // Vector(N, T) is a built-in parameterized type @@ -243,7 +269,7 @@ fn resolveParameterizedType(pt: *const ast.ParameterizedTypeExpr, table: *TypeTa .int_literal => |lit| @intCast(@as(u64, @bitCast(lit.value))), else => 0, }; - const elem = resolveAstType(pt.args[1], table, alias_map); + const elem = resolveAstType(pt.args[1], table, alias_map, consts); return table.vectorOf(elem, length); } } @@ -254,7 +280,7 @@ fn resolveParameterizedType(pt: *const ast.ParameterizedTypeExpr, table: *TypeTa // ── Inline type declarations ───────────────────────────────────────── -fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: AliasMap) TypeId { +fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId { const alloc = table.alloc; const name_id = table.internString(ed.name); @@ -280,7 +306,7 @@ fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia } else { var sfields = std.ArrayList(TypeInfo.StructInfo.Field).empty; for (sd.field_names, sd.field_types) |fname, ftype_node| { - const fty = resolveAstType(ftype_node, table, alias_map); + const fty = resolveAstType(ftype_node, table, alias_map, consts); sfields.append(alloc, .{ .name = table.internString(fname), .ty = fty, @@ -294,10 +320,10 @@ fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia table.update(field_ty, sinfo); } } else { - field_ty = resolveAstType(vt, table, alias_map); + field_ty = resolveAstType(vt, table, alias_map, consts); } } else { - field_ty = resolveAstType(vt, table, alias_map); + field_ty = resolveAstType(vt, table, alias_map, consts); } } } @@ -311,7 +337,7 @@ fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia var backing_type: ?TypeId = null; var tag_type: ?TypeId = null; if (ed.backing_type) |bt| { - const backing_ty = resolveAstType(bt, table, alias_map); + const backing_ty = resolveAstType(bt, table, alias_map, consts); backing_type = backing_ty; // Extract tag type from first field of backing struct const backing_info = table.get(backing_ty); @@ -394,7 +420,7 @@ fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia if (ed.backing_type) |bt| { // Only use simple backing types (u8, u16, u32, etc.), not struct backing (enum struct) if (bt.data != .struct_decl) { - enum_backing = resolveAstType(bt, table, alias_map); + enum_backing = resolveAstType(bt, table, alias_map, consts); } } @@ -410,7 +436,7 @@ fn resolveInlineEnum(ed: *const ast.EnumDecl, table: *TypeTable, alias_map: Alia return id; } -fn resolveInlineStruct(sd: *const ast.StructDecl, table: *TypeTable, alias_map: AliasMap) TypeId { +fn resolveInlineStruct(sd: *const ast.StructDecl, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId { const alloc = table.alloc; const name_id = table.internString(sd.name); @@ -418,7 +444,7 @@ fn resolveInlineStruct(sd: *const ast.StructDecl, table: *TypeTable, alias_map: var fields = std.ArrayList(TypeInfo.StructInfo.Field).empty; for (sd.field_names, sd.field_types) |fname, ftype_node| { - const field_ty = resolveAstType(ftype_node, table, alias_map); + const field_ty = resolveAstType(ftype_node, table, alias_map, consts); fields.append(alloc, .{ .name = table.internString(fname), .ty = field_ty, @@ -433,7 +459,7 @@ fn resolveInlineStruct(sd: *const ast.StructDecl, table: *TypeTable, alias_map: return id; } -fn resolveInlineUnion(ud: *const ast.UnionDecl, table: *TypeTable, alias_map: AliasMap) TypeId { +fn resolveInlineUnion(ud: *const ast.UnionDecl, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) TypeId { const alloc = table.alloc; const name_id = table.internString(ud.name); @@ -441,7 +467,7 @@ fn resolveInlineUnion(ud: *const ast.UnionDecl, table: *TypeTable, alias_map: Al var fields = std.ArrayList(TypeInfo.StructInfo.Field).empty; for (ud.field_names, ud.field_types) |fname, ftype_node| { - const field_ty = resolveAstType(ftype_node, table, alias_map); + const field_ty = resolveAstType(ftype_node, table, alias_map, consts); fields.append(alloc, .{ .name = table.internString(fname), .ty = field_ty, From d2bf8f3f2d912e1f50e35382f8a08e7192e78b0b Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 09:39:18 +0300 Subject: [PATCH 03/13] fix(ir): unify named-const array-dim resolution + kill length-0 fabrication (0083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A type alias whose dimension is a named const (`Arr :: [N]T`) resolves its dimension eagerly during scanDecls pass 1, on the stateless registration path, which can only read `module_const_map`. Typed consts (`N : s64 : 16`) register only in pass 2 and a forward-declared untyped const had not registered yet, so the stateless resolver saw an empty table, printed a non-fatal warning, fabricated length 0, and continued — yielding a 0-byte alloca, garbage reads, and a segfault for slice/struct elements. - scanDecls pass 0 pre-registers every integer-valued module const before any type alias resolves, so typed, untyped, and forward-referenced consts all resolve identically. - Both dim resolvers now share `program_index.moduleConstInt`, so the stateful body-lowering path and the stateless registration path cannot diverge. - `resolveArrayLen` returns `?u32`; `resolveCompound` yields `.unresolved` on null instead of a 0-length array. The stateful path emits a diagnostic; the alias-registration path surfaces an unresolved alias as a clean compile error that aborts the build. The Vector lane-count `else => 0` is fixed the same way. Regressions: examples/0143 (typed-const dim direct + via alias for s64/string/ struct, forward-ref alias, nested) and examples/1129 (an unresolvable computed dim halts with a clean diagnostic + non-zero exit). Both fail on the pre-fix compiler (garbage/segfault; warning+exit0) and pass after. --- examples/0143-types-typed-const-array-dim.sx | 65 +++++++++++++++++++ .../1129-diagnostics-array-dim-not-const.sx | 20 ++++++ .../0143-types-typed-const-array-dim.exit | 1 + .../0143-types-typed-const-array-dim.stderr | 1 + .../0143-types-typed-const-array-dim.stdout | 6 ++ .../1129-diagnostics-array-dim-not-const.exit | 1 + ...129-diagnostics-array-dim-not-const.stderr | 5 ++ ...129-diagnostics-array-dim-not-const.stdout | 1 + ...named-const-array-dimension-miscompiled.md | 24 +++++++ src/ir/lower.zig | 46 ++++++++++--- src/ir/program_index.zig | 15 +++++ src/ir/type_bridge.zig | 44 +++++++------ src/ir/type_resolver.test.zig | 4 +- src/ir/type_resolver.zig | 12 ++-- 14 files changed, 211 insertions(+), 34 deletions(-) create mode 100644 examples/0143-types-typed-const-array-dim.sx create mode 100644 examples/1129-diagnostics-array-dim-not-const.sx create mode 100644 examples/expected/0143-types-typed-const-array-dim.exit create mode 100644 examples/expected/0143-types-typed-const-array-dim.stderr create mode 100644 examples/expected/0143-types-typed-const-array-dim.stdout create mode 100644 examples/expected/1129-diagnostics-array-dim-not-const.exit create mode 100644 examples/expected/1129-diagnostics-array-dim-not-const.stderr create mode 100644 examples/expected/1129-diagnostics-array-dim-not-const.stdout diff --git a/examples/0143-types-typed-const-array-dim.sx b/examples/0143-types-typed-const-array-dim.sx new file mode 100644 index 0000000..b29831d --- /dev/null +++ b/examples/0143-types-typed-const-array-dim.sx @@ -0,0 +1,65 @@ +// A named-const array dimension lays out identically whether the const is +// TYPED (`N : s64 : 16`) or untyped (`N :: 16`), used DIRECTLY (`a : [N]T`) or +// through a type alias (`Arr :: [N]T`), and regardless of whether the const is +// declared before or after the alias that consumes it. +// +// Regression (issue 0083): the stateless registration-time resolver +// (type_bridge) only saw module consts that were already in `module_const_map` +// when a type alias resolved its dimension. Typed consts register in a later +// pass, and a forward-declared untyped const had not registered yet — so the +// alias dimension fabricated length 0 (a 0-byte alloca), and element access +// returned garbage (scalars) or bus-errored (slice/struct elements). Module +// consts are now pre-registered before any alias resolves, and both the +// stateful and stateless paths share one dimension resolver. +#import "modules/std.sx"; + +NT : s64 : 8; // typed const used as a dimension + +P :: struct { x: s64; y: s64; } + +// Type aliases whose dimension is the TYPED const NT (stateless registration). +TArr :: [NT]s64; +TSArr :: [NT]string; +TPArr :: [NT]P; + +// Forward reference: this alias is declared BEFORE its dimension const NF. +FArr :: [NF]s64; +NF :: 5; + +main :: () { + // Typed-const dimension, DIRECT local decl. + d : [NT]s64 = ---; + d[0] = 3; + d[7] = 21; + print("direct d0={} d7={} len={}\n", d[0], d[7], d.len); + + // Typed-const dimension via ALIAS (scalar): same layout as the direct form. + a : TArr = ---; + a[0] = 7; + a[7] = 99; + print("alias a0={} a7={} len={}\n", a[0], a[7], a.len); + + // Typed-const dimension via ALIAS (string elements): no bus error. + s : TSArr = ---; + s[0] = "hi"; + s[7] = "yo"; + print("alias s0={} s7={}\n", s[0], s[7]); + + // Typed-const dimension via ALIAS (struct elements). + ps : TPArr = ---; + ps[0] = P.{ x = 1, y = 2 }; + ps[7] = P.{ x = 5, y = 6 }; + print("alias p0x={} p0y={} p7x={}\n", ps[0].x, ps[0].y, ps[7].x); + + // Nested fixed array whose both dimensions are the typed const NT. + grid : [NT][NT]s64 = ---; + grid[0][0] = 1; + grid[7][7] = 10; + print("nested g00={} g77={}\n", grid[0][0], grid[7][7]); + + // Forward-referenced alias dimension (untyped const declared after it). + f : FArr = ---; + f[0] = 4; + f[4] = 40; + print("fwd f0={} f4={} len={}\n", f[0], f[4], f.len); +} diff --git a/examples/1129-diagnostics-array-dim-not-const.sx b/examples/1129-diagnostics-array-dim-not-const.sx new file mode 100644 index 0000000..06060c3 --- /dev/null +++ b/examples/1129-diagnostics-array-dim-not-const.sx @@ -0,0 +1,20 @@ +// An array dimension that is not a compile-time integer constant is a hard +// error, not a silently-fabricated 0-length array. Here a type alias's +// dimension is a computed expression (`M + 1`), which the registration-time +// resolver cannot evaluate. +// +// Regression (issue 0083): the stateless resolver printed a non-fatal warning +// and fabricated length 0, then let compilation continue — producing a 0-byte +// alloca and corrupt element access. It now yields the `.unresolved` sentinel, +// which the alias registration surfaces as this diagnostic, aborting the build +// with a non-zero exit. +#import "modules/std.sx"; + +M :: 4; +BadArr :: [M + 1]s64; + +main :: () { + a : BadArr = ---; + a[0] = 7; + print("a0={}\n", a[0]); +} diff --git a/examples/expected/0143-types-typed-const-array-dim.exit b/examples/expected/0143-types-typed-const-array-dim.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0143-types-typed-const-array-dim.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0143-types-typed-const-array-dim.stderr b/examples/expected/0143-types-typed-const-array-dim.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0143-types-typed-const-array-dim.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0143-types-typed-const-array-dim.stdout b/examples/expected/0143-types-typed-const-array-dim.stdout new file mode 100644 index 0000000..c92dfb8 --- /dev/null +++ b/examples/expected/0143-types-typed-const-array-dim.stdout @@ -0,0 +1,6 @@ +direct d0=3 d7=21 len=8 +alias a0=7 a7=99 len=8 +alias s0=hi s7=yo +alias p0x=1 p0y=2 p7x=5 +nested g00=1 g77=10 +fwd f0=4 f4=40 len=5 diff --git a/examples/expected/1129-diagnostics-array-dim-not-const.exit b/examples/expected/1129-diagnostics-array-dim-not-const.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1129-diagnostics-array-dim-not-const.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1129-diagnostics-array-dim-not-const.stderr b/examples/expected/1129-diagnostics-array-dim-not-const.stderr new file mode 100644 index 0000000..5b169d8 --- /dev/null +++ b/examples/expected/1129-diagnostics-array-dim-not-const.stderr @@ -0,0 +1,5 @@ +error: type alias 'BadArr' could not be resolved: an array dimension is not a compile-time integer constant + --> examples/1129-diagnostics-array-dim-not-const.sx:14:11 + | +14 | BadArr :: [M + 1]s64; + | ^^^^^^^^^^ diff --git a/examples/expected/1129-diagnostics-array-dim-not-const.stdout b/examples/expected/1129-diagnostics-array-dim-not-const.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1129-diagnostics-array-dim-not-const.stdout @@ -0,0 +1 @@ + diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index 73ab66e..1c6ee31 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -25,6 +25,30 @@ > `src/ir/type_bridge.zig`. Regression: `examples/0140-types-named-const-array-dim.sx` > (direct + type-alias + nested `[N][M]T` + union-field dims, s64 / string / > struct element types). +> +> **Root-cause close-out (attempt 3).** Attempt 2 threaded the const map into +> `type_bridge` but the map wasn't fully populated when an alias resolved its +> dimension: type aliases (`Arr :: [N]T`) resolve EAGERLY in scanDecls pass 1, +> while TYPED consts (`N : s64 : 16`) register only in pass 2 and a +> forward-declared untyped const (`Arr :: [N]T; N :: 16`) hadn't registered yet +> either — so the stateless resolver saw an empty table, printed a non-fatal +> warning, fabricated length 0, and CONTINUED to garbage / a segfault. Three +> coordinated fixes: (1) a scanDecls **pass 0** pre-registers every integer-valued +> module const into `module_const_map` BEFORE any alias resolves, so typed, +> untyped, and forward-referenced consts all resolve identically; (2) both the +> stateful and stateless dim resolvers now share one routine +> (`program_index.moduleConstInt`) so they cannot disagree again; (3) the length-0 +> fabrications are GONE — `resolveArrayLen` returns `?u32`, `resolveCompound` +> yields the `.unresolved` sentinel on null (never a 0-byte array), the stateful +> path emits a diagnostic, and the registration path surfaces an unresolved alias +> as a clean compile error that aborts the build (the `type_bridge.zig:270` +> Vector-lane `else => 0` is fixed the same way). Files: +> `src/ir/program_index.zig`, `src/ir/lower.zig`, `src/ir/type_bridge.zig`, +> `src/ir/type_resolver.zig`. Regressions: +> `examples/0143-types-typed-const-array-dim.sx` (typed-const dim direct + via +> alias for s64/string/struct, forward-ref alias, nested) and +> `examples/1129-diagnostics-array-dim-not-const.sx` (an unresolvable computed dim +> halts with a clean diagnostic + non-zero exit, not a fabricated 0-length array). ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 5045547..175a189 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -653,6 +653,24 @@ pub const Lowering = struct { /// Pass 1: Scan declarations — register ASTs and extern stubs, but don't lower bodies. fn scanDecls(self: *Lowering, decls: []const *const Node) void { + // Pass 0: register every integer-valued module const (`N :: 16` and the + // typed `N : s64 : 16`) BEFORE any type alias is resolved below. A type + // alias whose dimension is a named const (`Arr :: [N]T`) resolves its + // dimension eagerly here, on the stateless registration path; that path + // can only read `module_const_map`. Untyped consts would otherwise be + // registered only in declaration order (pass 1) and typed ones only after + // the alias fixpoint (pass 2) — so an alias declared before its const, or + // any alias over a typed const, saw an empty table and miscompiled the + // dimension to length 0 (issue 0083). The dimension only needs the value, + // so a placeholder type is fine; pass 2 overwrites typed consts with the + // resolved annotation type (issue 0070). + for (decls) |decl| { + if (decl.data != .const_decl) continue; + const cd = decl.data.const_decl; + if (cd.value.data == .int_literal) { + self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .s64 }) catch {}; + } + } for (decls) |decl| { self.setCurrentSourceFile(decl.source_file); const is_imported = if (self.main_file) |mf| @@ -689,6 +707,16 @@ pub const Lowering = struct { { // Type alias: MyFloat :: f64; Ptr :: *u8; Cb :: (s32) -> s32; const target_ty = type_bridge.resolveAstType(cd.value, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); + // The stateless resolver yields `.unresolved` for a shape + // it cannot build — e.g. `Arr :: []T`, whose + // dimension is not a compile-time integer constant. Surface + // it as a clean diagnostic so the build aborts here rather + // than letting `.unresolved` reach codegen and `@panic` in + // sizeOf (issue 0083 — no fabricated 0-length array). + if (target_ty == .unresolved) { + if (self.diagnostics) |d| + d.addFmt(.err, cd.value.span, "type alias '{s}' could not be resolved: an array dimension is not a compile-time integer constant", .{cd.name}); + } self.program_index.type_alias_map.put(cd.name, target_ty) catch {}; } else if (cd.value.data == .identifier) { // Identifier-RHS alias: MyAlias :: MyInt; WideAlias :: Wide; @@ -11634,10 +11662,12 @@ pub const Lowering = struct { /// `[16]T` and a named-const `N :: 16; [N]T` must resolve to the SAME length: /// the dimension is a compile-time integer, looked up in the comptime / value /// / module-const tables the stateful lowering owns. A dimension that isn't a - /// compile-time integer is a hard error — emitting a diagnostic (rather than - /// fabricating a 0 length, which gives a 0-byte array and out-of-bounds - /// element access, issue 0083). - pub fn resolveArrayLen(self: *Lowering, len_node: *const Node) u32 { + /// compile-time integer is a hard error: emit a diagnostic so the driver + /// aborts (`hasErrors()`), then return a harmless `0` so body lowering + /// finishes without touching the `.unresolved` sentinel (which would `@panic` + /// in `sizeOf` mid-lowering, before the diagnostic surfaces). The diagnostic — + /// not the returned length — is what guarantees no garbage ships (issue 0083). + pub fn resolveArrayLen(self: *Lowering, len_node: *const Node) ?u32 { if (self.comptimeArrayDim(len_node)) |n| { if (n < 0) { if (self.diagnostics) |d| @@ -11673,10 +11703,10 @@ pub const Lowering = struct { if (self.comptime_value_bindings) |cvb| { if (cvb.get(name)) |v| return v; } - if (self.program_index.module_const_map.get(name)) |ci| { - if (ci.value.data == .int_literal) return ci.value.data.int_literal.value; - } - return null; + // The module-const branch is shared verbatim with the stateless + // registration-time resolver (`type_bridge`) so a `[N]T` dimension + // resolves to the same length on both paths (issue 0083). + return program_index_mod.moduleConstInt(&self.program_index.module_const_map, name); } /// Resolve a type node, checking type_bindings first for generic type params. diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index a90d04c..a93cf75 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -40,6 +40,21 @@ pub const ModuleConstInfo = struct { ty: TypeId, }; +/// A name bound to a module-global integer constant → its value, else null. +/// SINGLE source for both array-dimension resolvers — the stateful +/// body-lowering path (`Lowering.comptimeIntNamed`) and the stateless +/// registration-time path (`type_bridge.StatelessInner`). They must agree on +/// which named consts a `[N]T` dimension resolves to; if they diverge, an array +/// laid out via a type alias (`Arr :: [N]T`, stateless) gets a different length +/// than the direct form (`a : [N]T`, stateful) — the issue-0083 miscompile. +/// Untyped (`N :: 16`) and typed (`N : s64 : 16`) consts both store an +/// `.int_literal` value node, so both resolve here identically. +pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), name: []const u8) ?i64 { + const ci = consts.get(name) orelse return null; + if (ci.value.data == .int_literal) return ci.value.data.int_literal.value; + return null; +} + pub const GlobalInfo = struct { id: inst.GlobalId, ty: TypeId }; /// Single lowering access point for declaration-name / import / visibility diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index fc05de9..e93ec64 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -41,30 +41,32 @@ const StatelessInner = struct { return resolveAstType(node, self.table, self.alias_map, self.consts); } /// Fixed-array dimension at registration time: a literal `[16]T`, or a - /// named module-global const `N :: 16; [N]T` looked up in the const table. - /// Both yield the SAME length — registration-time paths (aliases, inline - /// union/enum fields) must lay out a named-const dim identically to a literal - /// (issue 0083). A dimension that is neither is not resolvable on this - /// binding-free path (it would be a computed/comptime expression, which the - /// stateful body-lowering path diagnoses as a hard error at the storage - /// site); bail LOUDLY rather than fabricating a 0 length that silently gives a - /// 0-byte array and out-of-bounds element access. - pub fn resolveArrayLen(self: StatelessInner, len_node: *const Node) u32 { + /// named module-global const `N :: 16; [N]T` (typed `N : s64 : 16` too) + /// looked up in the const table. Both yield the SAME length — registration- + /// time paths (aliases, inline union/enum fields) must lay out a named-const + /// dim identically to a literal (issue 0083). Returns null when the dimension + /// is neither (a computed/comptime expression, or a name not bound to an + /// integer const). Null propagates to `resolveCompound`, which yields the + /// `.unresolved` sentinel rather than fabricating a 0 length that silently + /// gives a 0-byte array and out-of-bounds element access; the registration + /// caller surfaces the unresolved alias/type as a clean diagnostic. + pub fn resolveArrayLen(self: StatelessInner, len_node: *const Node) ?u32 { switch (len_node.data) { - .int_literal => |lit| return @intCast(lit.value), + .int_literal => |lit| return if (lit.value >= 0) @intCast(lit.value) else null, .identifier => |id| if (self.namedConstLen(id.name)) |n| return n, .type_expr => |te| if (self.namedConstLen(te.name)) |n| return n, else => {}, } - std.debug.print("type_bridge: array dimension is not a literal or named integer constant — cannot resolve length at registration time (computed/comptime dimensions are unsupported here)\n", .{}); - return 0; + return null; } - /// A name that resolves to a module-global integer constant → its value. + /// A name that resolves to a non-negative module-global integer constant → + /// its value. Shares `program_index.moduleConstInt` with the stateful + /// body-lowering resolver so the two paths cannot disagree on which named + /// consts a dimension resolves to (issue 0083). fn namedConstLen(self: StatelessInner, name: []const u8) ?u32 { const consts = self.consts orelse return null; - const ci = consts.get(name) orelse return null; - if (ci.value.data == .int_literal) return @intCast(ci.value.data.int_literal.value); - return null; + const v = program_index_mod.moduleConstInt(consts, name) orelse return null; + return if (v >= 0) @intCast(v) else null; } }; @@ -265,10 +267,12 @@ fn resolveParameterizedType(pt: *const ast.ParameterizedTypeExpr, table: *TypeTa // Vector(N, T) is a built-in parameterized type if (std.mem.eql(u8, base_name, "Vector")) { if (pt.args.len == 2) { - const length: u32 = switch (pt.args[0].data) { - .int_literal => |lit| @intCast(@as(u64, @bitCast(lit.value))), - else => 0, - }; + // The lane count is a literal or a named module-const integer — the + // same dimension forms a fixed array accepts. An unresolvable count + // is NOT a 0-lane vector (which would silently mis-size every load / + // store); yield `.unresolved` so the failure surfaces (issue 0083). + const si = StatelessInner{ .table = table, .alias_map = alias_map, .consts = consts }; + const length = si.resolveArrayLen(pt.args[0]) orelse return .unresolved; const elem = resolveAstType(pt.args[1], table, alias_map, consts); return table.vectorOf(elem, length); } diff --git a/src/ir/type_resolver.test.zig b/src/ir/type_resolver.test.zig index ad51f8f..0d415bb 100644 --- a/src/ir/type_resolver.test.zig +++ b/src/ir/type_resolver.test.zig @@ -23,10 +23,10 @@ const PrimInner = struct { else => .unresolved, }; } - pub fn resolveArrayLen(_: PrimInner, len_node: *const Node) u32 { + pub fn resolveArrayLen(_: PrimInner, len_node: *const Node) ?u32 { return switch (len_node.data) { .int_literal => |lit| @intCast(lit.value), - else => 0, + else => null, }; } }; diff --git a/src/ir/type_resolver.zig b/src/ir/type_resolver.zig index 7a710d9..e9075e3 100644 --- a/src/ir/type_resolver.zig +++ b/src/ir/type_resolver.zig @@ -95,10 +95,14 @@ pub const TypeResolver = struct { const elem = inner.resolveInner(at.element_type); // The dimension is delegated to `inner` exactly like the element // type: a literal `[16]T` and a named-const `N :: 16; [N]T` must - // produce the same length. The stateful resolver consults the - // const tables; the binding-free one handles literal dims (issue - // 0083 — a 0 here gives a 0-byte array and OOB element access). - const len = inner.resolveArrayLen(at.length); + // produce the same length. `resolveArrayLen` returns null when the + // dimension can't be resolved to a compile-time integer; that is + // never a 0-length array (which gives a 0-byte alloca and OOB + // element access — issue 0083). Yield the `.unresolved` sentinel + // instead, so the failure halts the build (the stateful resolver + // also emits a diagnostic; the registration-time caller surfaces + // the unresolved alias) rather than silently miscompiling. + const len = inner.resolveArrayLen(at.length) orelse break :blk TypeId.unresolved; break :blk table.arrayOf(elem, len); }, .function_type_expr => |ft| blk: { From cd39316f5eeac4a92d5c6c5edeb5168245d874cc Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 10:38:21 +0300 Subject: [PATCH 04/13] fix(ir): evaluate constant-expression array dimensions (0083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A constant-FOLDABLE expression array dimension (`[M + 1]`, `[M * N]`, `[N - M]`, nested `[M + N - 1]`, parenthesised `[(M + 1) * 2]`, mixing untyped and typed module consts) was wrongly rejected as "not a compile-time integer constant" even though every operand is compile-time-known. Attempts 1-3 resolved only a bare named-const dim or a literal; an expression dim must be EVALUATED, not rejected. Fix: the shared dim resolver now routes the dimension through a single constant integer-expression evaluator (`program_index.evalConstIntExpr`) that folds integer `+ - * / %` and unary negate over literals and named/typed module consts, recursively (parentheses carry no AST node). The leaf-name lookup is delegated via `ctx.lookupDimName`, so the stateful body-lowering path (`Lowering`, which also sees comptime constants and generic `$N` values) and the stateless registration path (`type_bridge.StatelessInner`, module consts only) share the EXACT SAME folding logic and cannot diverge — an expression dim via a type alias resolves identically to the direct form. No-fabrication discipline unchanged: a genuinely non-comptime dimension (runtime local, non-comptime call, unbound name) or arithmetic that overflows / divides by zero still yields null -> `.unresolved` -> the same clean compile-halting diagnostic, never a fabricated length. - examples/0144-types-const-expr-array-dim.sx: every expression form, direct vs alias, scalar / string / struct element types (fails on the pre-fix compiler, passes after). - examples/1129 re-pointed at a genuinely non-const dimension (`[get()]s64`, a runtime call) so it still proves the stateless clean-halt (a foldable expression is no longer an error). - program_index.test.zig: unit test for evalConstIntExpr folding and clean-halt-on-non-const. --- examples/0144-types-const-expr-array-dim.sx | 77 +++++++++++++++++++ .../1129-diagnostics-array-dim-not-const.sx | 12 ++- .../0144-types-const-expr-array-dim.exit | 1 + .../0144-types-const-expr-array-dim.stderr | 1 + .../0144-types-const-expr-array-dim.stdout | 7 ++ ...129-diagnostics-array-dim-not-const.stderr | 4 +- ...named-const-array-dimension-miscompiled.md | 23 ++++++ src/ir/lower.zig | 25 +++--- src/ir/program_index.test.zig | 74 ++++++++++++++++++ src/ir/program_index.zig | 42 ++++++++++ src/ir/type_bridge.zig | 50 ++++++------ 11 files changed, 275 insertions(+), 41 deletions(-) create mode 100644 examples/0144-types-const-expr-array-dim.sx create mode 100644 examples/expected/0144-types-const-expr-array-dim.exit create mode 100644 examples/expected/0144-types-const-expr-array-dim.stderr create mode 100644 examples/expected/0144-types-const-expr-array-dim.stdout diff --git a/examples/0144-types-const-expr-array-dim.sx b/examples/0144-types-const-expr-array-dim.sx new file mode 100644 index 0000000..3551300 --- /dev/null +++ b/examples/0144-types-const-expr-array-dim.sx @@ -0,0 +1,77 @@ +// A constant-FOLDABLE expression array dimension (`[M + 1]`, `[M * N]`, +// `[N - M]`, nested `[M + N - 1]`, parenthesised `[(M + 1) * 2]`, and an +// expression mixing an untyped and a typed module const) resolves to its +// evaluated length — IDENTICALLY whether used DIRECTLY (`a : [M + 1]T`) or +// through a type alias (`A :: [M + 1]T`), and for scalar, string (slice/pointer +// class), and struct element types. +// +// Regression (issue 0083): the shared array-dimension resolver only looked up a +// bare named const or a literal; any const-foldable EXPRESSION dimension was +// rejected as "not a compile-time integer constant". It now routes the +// dimension through the shared comptime integer-expression evaluator +// (`program_index.evalConstIntExpr`), so integer `+ - * /` and parenthesisation +// over literals and module consts fold on BOTH the stateful (direct) and +// stateless (alias) paths — they share the one evaluator and cannot diverge. +#import "modules/std.sx"; + +M :: 4; +N :: 6; +TK : s64 : 2; // typed const, used inside an expression dimension + +P :: struct { x: s64; y: s64; } + +AddAlias :: [M + 1]s64; // 5 +MulAlias :: [M * N]s64; // 24 +SubAlias :: [N - M]s64; // 2 +NestAlias :: [M + N - 1]s64; // 9 +ParenAlias :: [(M + 1) * 2]s64; // 10 +TypedAlias :: [M + TK]s64; // 6 +StrAlias :: [M + 1]string; // 5, slice/pointer elements +StructAlias :: [M + 1]P; // 5, struct elements + +main :: () { + // const + literal: direct and via alias resolve to the same length. + add_d : [M + 1]s64 = ---; + add_a : AddAlias = ---; + add_d[4] = 7; + add_a[4] = 7; + print("add direct.len={} alias.len={} d4={} a4={}\n", add_d.len, add_a.len, add_d[4], add_a[4]); + + // const * const. + mul_d : [M * N]s64 = ---; + mul_a : MulAlias = ---; + mul_d[23] = 230; + mul_a[23] = 230; + print("mul direct.len={} alias.len={} d23={} a23={}\n", mul_d.len, mul_a.len, mul_d[23], mul_a[23]); + + // const - const. + sub_d : [N - M]s64 = ---; + sub_a : SubAlias = ---; + sub_d[1] = 9; + sub_a[1] = 9; + print("sub direct.len={} alias.len={} d1={} a1={}\n", sub_d.len, sub_a.len, sub_d[1], sub_a[1]); + + // nested and parenthesised forms (direct vs alias). + nest_d : [M + N - 1]s64 = ---; + nest_a : NestAlias = ---; + paren_d : [(M + 1) * 2]s64 = ---; + paren_a : ParenAlias = ---; + print("nest direct.len={} alias.len={} paren direct.len={} alias.len={}\n", nest_d.len, nest_a.len, paren_d.len, paren_a.len); + + // typed const inside the expression dimension. + typ_d : [M + TK]s64 = ---; + typ_a : TypedAlias = ---; + print("typed direct.len={} alias.len={}\n", typ_d.len, typ_a.len); + + // string elements (slice/pointer class) — no bus error, correct reads. + str_a : StrAlias = ---; + str_a[0] = "hi"; + str_a[4] = "yo"; + print("str alias.len={} s0={} s4={}\n", str_a.len, str_a[0], str_a[4]); + + // struct elements. + ps : StructAlias = ---; + ps[0] = P.{ x = 1, y = 2 }; + ps[4] = P.{ x = 5, y = 6 }; + print("struct alias.len={} p0x={} p4y={}\n", ps.len, ps[0].x, ps[4].y); +} diff --git a/examples/1129-diagnostics-array-dim-not-const.sx b/examples/1129-diagnostics-array-dim-not-const.sx index 06060c3..3e74efb 100644 --- a/examples/1129-diagnostics-array-dim-not-const.sx +++ b/examples/1129-diagnostics-array-dim-not-const.sx @@ -1,7 +1,11 @@ // An array dimension that is not a compile-time integer constant is a hard // error, not a silently-fabricated 0-length array. Here a type alias's -// dimension is a computed expression (`M + 1`), which the registration-time -// resolver cannot evaluate. +// dimension is a runtime function call (`get()`), which is genuinely not +// compile-time-known — the registration-time resolver cannot evaluate it. +// +// (A const-FOLDABLE expression dimension such as `[M + 1]` is NOT an error — it +// folds; see examples/0144-types-const-expr-array-dim.sx. Only a dimension with +// a genuinely runtime operand halts here.) // // Regression (issue 0083): the stateless resolver printed a non-fatal warning // and fabricated length 0, then let compilation continue — producing a 0-byte @@ -10,8 +14,8 @@ // with a non-zero exit. #import "modules/std.sx"; -M :: 4; -BadArr :: [M + 1]s64; +get :: () -> s64 { return 5; } +BadArr :: [get()]s64; main :: () { a : BadArr = ---; diff --git a/examples/expected/0144-types-const-expr-array-dim.exit b/examples/expected/0144-types-const-expr-array-dim.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0144-types-const-expr-array-dim.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0144-types-const-expr-array-dim.stderr b/examples/expected/0144-types-const-expr-array-dim.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0144-types-const-expr-array-dim.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0144-types-const-expr-array-dim.stdout b/examples/expected/0144-types-const-expr-array-dim.stdout new file mode 100644 index 0000000..9b287b7 --- /dev/null +++ b/examples/expected/0144-types-const-expr-array-dim.stdout @@ -0,0 +1,7 @@ +add direct.len=5 alias.len=5 d4=7 a4=7 +mul direct.len=24 alias.len=24 d23=230 a23=230 +sub direct.len=2 alias.len=2 d1=9 a1=9 +nest direct.len=9 alias.len=9 paren direct.len=10 alias.len=10 +typed direct.len=6 alias.len=6 +str alias.len=5 s0=hi s4=yo +struct alias.len=5 p0x=1 p4y=6 diff --git a/examples/expected/1129-diagnostics-array-dim-not-const.stderr b/examples/expected/1129-diagnostics-array-dim-not-const.stderr index 5b169d8..d963fa8 100644 --- a/examples/expected/1129-diagnostics-array-dim-not-const.stderr +++ b/examples/expected/1129-diagnostics-array-dim-not-const.stderr @@ -1,5 +1,5 @@ error: type alias 'BadArr' could not be resolved: an array dimension is not a compile-time integer constant - --> examples/1129-diagnostics-array-dim-not-const.sx:14:11 + --> examples/1129-diagnostics-array-dim-not-const.sx:18:11 | -14 | BadArr :: [M + 1]s64; +18 | BadArr :: [get()]s64; | ^^^^^^^^^^ diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index 1c6ee31..a2a8292 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -49,6 +49,29 @@ > alias for s64/string/struct, forward-ref alias, nested) and > `examples/1129-diagnostics-array-dim-not-const.sx` (an unresolvable computed dim > halts with a clean diagnostic + non-zero exit, not a fabricated 0-length array). +> +> **Const-expression dimensions (attempt 4).** Attempts 1–3 resolved only a BARE +> named-const dim (`[M]`) or a literal (`[5]`); any constant-FOLDABLE *expression* +> dimension (`[M + 1]`, `[M * N]`, `[N - M]`, nested `[M + N - 1]`, parenthesised +> `[(M + 1) * 2]`) was wrongly rejected as "not a compile-time integer constant" +> even though every operand is compile-time-known. Such a dimension MUST be +> evaluated, not rejected. Fix: the shared dim resolver now routes the dimension +> through a single constant integer-expression evaluator +> (`program_index.evalConstIntExpr`) that folds integer `+ - * / %` and unary +> negate (parentheses carry no AST node) over literals and named/typed module +> consts, recursively. The leaf-name lookup is delegated (`ctx.lookupDimName`) so +> the stateful body-lowering path and the stateless registration path share the +> EXACT SAME folding logic and cannot diverge — an expression dim via a type alias +> resolves identically to the direct form. The no-fabrication discipline is +> unchanged: a genuinely non-comptime dimension (a runtime local, a non-comptime +> call, an unbound name) — or arithmetic that overflows / divides by zero — still +> yields null → `.unresolved` → the same clean compile-halting diagnostic, never a +> fabricated length. Files: `src/ir/program_index.zig` (+`.test.zig`), +> `src/ir/lower.zig`, `src/ir/type_bridge.zig`. Regression: +> `examples/0144-types-const-expr-array-dim.sx` (every expression form, direct vs +> alias, scalar / string / struct element types); `1129` re-pointed at a genuinely +> non-const dimension (`[get()]s64`, a runtime call) so it still proves the +> stateless clean-halt. ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 175a189..2880b97 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -11681,17 +11681,22 @@ pub const Lowering = struct { return 0; } - /// Evaluate a fixed-array dimension to a compile-time integer: a literal, or - /// a name bound to an integer in the comptime-constant (`OS`/loop cursors), - /// generic-value (`$N`), or module-global const (`N :: 16`) tables. Returns - /// null when the dimension isn't a compile-time integer. + /// Evaluate a fixed-array dimension to a compile-time integer: a literal, a + /// name bound to an integer (comptime-constant `OS`/loop cursors, generic + /// `$N` value, or module-global const `N :: 16`), or a constant-foldable + /// expression over those (`[M + 1]`, `[(M + 1) * 2]`). Delegates the + /// expression folding to the shared `program_index.evalConstIntExpr` so this + /// body-lowering path and the stateless registration path cannot diverge on + /// a dimension's value. Returns null when the dimension isn't a compile-time + /// integer. fn comptimeArrayDim(self: *Lowering, node: *const Node) ?i64 { - return switch (node.data) { - .int_literal => |lit| lit.value, - .identifier => |id| self.comptimeIntNamed(id.name), - .type_expr => |te| self.comptimeIntNamed(te.name), - else => null, - }; + return program_index_mod.evalConstIntExpr(node, self); + } + + /// Leaf-name lookup for the shared dimension evaluator: a name bound to a + /// compile-time integer across the three const tables. + pub fn lookupDimName(self: *Lowering, name: []const u8) ?i64 { + return self.comptimeIntNamed(name); } /// Resolve a name to a compile-time integer across the three const tables. diff --git a/src/ir/program_index.test.zig b/src/ir/program_index.test.zig index 34acb5d..62aeae4 100644 --- a/src/ir/program_index.test.zig +++ b/src/ir/program_index.test.zig @@ -96,3 +96,77 @@ test "ProgramIndex declaration maps round-trip (A1.1b)" { try idx.ufcs_alias_map.put("len", "list_len"); try std.testing.expectEqualStrings("list_len", idx.ufcs_alias_map.get("len").?); } + +/// Stand-in for the leaf-name lookup both array-dimension resolvers pass to the +/// shared `evalConstIntExpr`: `M`/`N` resolve to integers, everything else is +/// genuinely non-comptime. +const DimCtx = struct { + pub fn lookupDimName(_: DimCtx, name: []const u8) ?i64 { + if (std.mem.eql(u8, name, "M")) return 4; + if (std.mem.eql(u8, name, "N")) return 6; + return null; + } +}; + +fn nLit(v: i64) ast.Node { + return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .int_literal = .{ .value = v } } }; +} +fn nIdent(name: []const u8) ast.Node { + return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = name } } }; +} +fn nBin(op: ast.BinaryOp.Op, l: *ast.Node, r: *ast.Node) ast.Node { + return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .binary_op = .{ .op = op, .lhs = l, .rhs = r } } }; +} +fn nNeg(operand: *ast.Node) ast.Node { + return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .unary_op = .{ .op = .negate, .operand = operand } } }; +} + +test "evalConstIntExpr folds constant-expression array dimensions, halts on non-const" { + const eval = pi.evalConstIntExpr; + const ctx = DimCtx{}; + + var l5 = nLit(5); + var one = nLit(1); + var two = nLit(2); + var zero = nLit(0); + var m = nIdent("M"); + var n = nIdent("N"); + var z = nIdent("Z"); // unbound — genuinely non-comptime + + // Leaves: literal, named const, unbound name. + try std.testing.expectEqual(@as(?i64, 5), eval(&l5, ctx)); + try std.testing.expectEqual(@as(?i64, 4), eval(&m, ctx)); + try std.testing.expect(eval(&z, ctx) == null); + + // `M + 1`, `M * N`, `N - M`. + var add = nBin(.add, &m, &one); + var mul = nBin(.mul, &m, &n); + var sub = nBin(.sub, &n, &m); + try std.testing.expectEqual(@as(?i64, 5), eval(&add, ctx)); + try std.testing.expectEqual(@as(?i64, 24), eval(&mul, ctx)); + try std.testing.expectEqual(@as(?i64, 2), eval(&sub, ctx)); + + // Nested `(M + N) - 1` and parenthesised `(M + 1) * 2` (parens carry no node). + var addmn = nBin(.add, &m, &n); + var nested = nBin(.sub, &addmn, &one); + var paren = nBin(.mul, &add, &two); + try std.testing.expectEqual(@as(?i64, 9), eval(&nested, ctx)); + try std.testing.expectEqual(@as(?i64, 10), eval(&paren, ctx)); + + // Unary negate. + var neg = nNeg(&m); + try std.testing.expectEqual(@as(?i64, -4), eval(&neg, ctx)); + + // Genuinely non-const operand, division by zero, a non-arithmetic operator, + // and overflow all yield null → the caller's clean compile-halt (no panic, + // no fabricated length). + var addz = nBin(.add, &m, &z); + var divz = nBin(.div, &m, &zero); + var cmp = nBin(.lt, &m, &n); + var big = nLit(std.math.maxInt(i64)); + var ovf = nBin(.mul, &big, &two); + try std.testing.expect(eval(&addz, ctx) == null); + try std.testing.expect(eval(&divz, ctx) == null); + try std.testing.expect(eval(&cmp, ctx) == null); + try std.testing.expect(eval(&ovf, ctx) == null); +} diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index a93cf75..f9a42b8 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -55,6 +55,48 @@ pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), name: [ return null; } +/// Evaluate a constant-expression array dimension to its integer value. Folds +/// integer `+ - * / %` and unary negate over int literals and named module / +/// comptime consts — recursively, so nested and parenthesised forms +/// (`[M + N - 1]`, `[(M + 1) * 2]`) fold (a grouping `(…)` carries no AST node; +/// the parser returns the inner expression). Leaf names resolve through +/// `ctx.lookupDimName`, so the stateful body-lowering path (which also sees +/// comptime constants and generic `$N` value bindings) and the stateless +/// registration path (module consts only) share THIS expression-folding logic +/// and cannot disagree on a dimension's value — the same unify-or-die rule that +/// keeps an array laid out via a type alias identical to the direct form +/// (issue 0083). Returns null when any operand is not a compile-time integer (a +/// runtime value, a non-comptime call, an unbound name) or the arithmetic +/// overflows / divides by zero: the caller then emits the clean compile-halting +/// diagnostic, never a fabricated length. +pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 { + return switch (node.data) { + .int_literal => |lit| lit.value, + .identifier => |id| ctx.lookupDimName(id.name), + .type_expr => |te| ctx.lookupDimName(te.name), + .unary_op => |u| switch (u.op) { + .negate => { + const v = evalConstIntExpr(u.operand, ctx) orelse return null; + return if (v == std.math.minInt(i64)) null else -v; + }, + else => null, + }, + .binary_op => |b| { + const l = evalConstIntExpr(b.lhs, ctx) orelse return null; + const r = evalConstIntExpr(b.rhs, ctx) orelse return null; + return switch (b.op) { + .add => std.math.add(i64, l, r) catch null, + .sub => std.math.sub(i64, l, r) catch null, + .mul => std.math.mul(i64, l, r) catch null, + .div => std.math.divTrunc(i64, l, r) catch null, + .mod => if (r == 0) null else @rem(l, r), + else => null, + }; + }, + else => null, + }; +} + pub const GlobalInfo = struct { id: inst.GlobalId, ty: TypeId }; /// Single lowering access point for declaration-name / import / visibility diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index e93ec64..8f07f97 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -40,34 +40,34 @@ const StatelessInner = struct { pub fn resolveInner(self: StatelessInner, node: *const Node) TypeId { return resolveAstType(node, self.table, self.alias_map, self.consts); } - /// Fixed-array dimension at registration time: a literal `[16]T`, or a - /// named module-global const `N :: 16; [N]T` (typed `N : s64 : 16` too) - /// looked up in the const table. Both yield the SAME length — registration- - /// time paths (aliases, inline union/enum fields) must lay out a named-const - /// dim identically to a literal (issue 0083). Returns null when the dimension - /// is neither (a computed/comptime expression, or a name not bound to an - /// integer const). Null propagates to `resolveCompound`, which yields the - /// `.unresolved` sentinel rather than fabricating a 0 length that silently - /// gives a 0-byte array and out-of-bounds element access; the registration - /// caller surfaces the unresolved alias/type as a clean diagnostic. + /// Fixed-array dimension at registration time: a literal `[16]T`, a named + /// module-global const `N :: 16; [N]T` (typed `N : s64 : 16` too), or a + /// constant-foldable expression over those (`[M + 1]`, `[(M + 1) * 2]`). + /// Folds through the shared `program_index.evalConstIntExpr` — the SAME + /// evaluator the stateful body-lowering path uses — so a dimension resolves + /// to one length on every registration-time path (aliases, inline union/enum + /// fields) and matches the direct form (issue 0083). Returns null when the + /// dimension isn't a compile-time integer (a runtime value / non-comptime + /// call, or a name not bound to an integer const). Null propagates to + /// `resolveCompound`, which yields the `.unresolved` sentinel rather than + /// fabricating a 0 length that silently gives a 0-byte array and + /// out-of-bounds element access; the registration caller surfaces the + /// unresolved alias/type as a clean diagnostic. pub fn resolveArrayLen(self: StatelessInner, len_node: *const Node) ?u32 { - switch (len_node.data) { - .int_literal => |lit| return if (lit.value >= 0) @intCast(lit.value) else null, - .identifier => |id| if (self.namedConstLen(id.name)) |n| return n, - .type_expr => |te| if (self.namedConstLen(te.name)) |n| return n, - else => {}, - } - return null; - } - /// A name that resolves to a non-negative module-global integer constant → - /// its value. Shares `program_index.moduleConstInt` with the stateful - /// body-lowering resolver so the two paths cannot disagree on which named - /// consts a dimension resolves to (issue 0083). - fn namedConstLen(self: StatelessInner, name: []const u8) ?u32 { - const consts = self.consts orelse return null; - const v = program_index_mod.moduleConstInt(consts, name) orelse return null; + const v = program_index_mod.evalConstIntExpr(len_node, self) orelse return null; return if (v >= 0) @intCast(v) else null; } + /// Leaf-name lookup for the shared dimension evaluator: a name that resolves + /// to a module-global integer constant → its value. Shares + /// `program_index.moduleConstInt` with the stateful body-lowering resolver so + /// the two paths cannot disagree on which named consts a dimension resolves + /// to (issue 0083). The non-negative check is applied once, on the final + /// dimension value in `resolveArrayLen` — not here, so an intermediate + /// operand may legitimately be negative. + pub fn lookupDimName(self: StatelessInner, name: []const u8) ?i64 { + const consts = self.consts orelse return null; + return program_index_mod.moduleConstInt(consts, name); + } }; // ── AST Node → TypeId ─────────────────────────────────────────────────── From a491a1bf733bf284b1a52f34b7e9199428dea004 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 11:32:25 +0300 Subject: [PATCH 05/13] fix(ir): route every comptime-int through the shared evaluator (0083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attempts 1–4 fixed the array-dimension paths but the same length-0 fabrication class survived on every other site that resolves a compile-time integer. Unify them all on the single shared `program_index.evalConstIntExpr` so they cannot diverge: - All three Vector lane resolvers (resolveTypeCallWithBindings, resolveParameterizedWithBindings, resolveArrayLiteralType) and both generic value-param binders (instantiateGenericStruct, instantiateTypeFunction) hand-rolled an `else => 0` switch. A module-const lane `Vector(N, f32)` fabricated a 0-lane `<0 x float>` (LLVM "huge alignment" abort); a value-param `Vec(N, f32)` fabricated a 0 binding / wrong mangled name. They now fold through the shared evaluator and emit a clean diagnostic + `.unresolved` on a non-const operand (resolveVectorLane / resolveValueParamArg) — never 0. - evalComptimeInt (inline-for bounds) delegated to the shared evaluator, so `inline for 0..M` / `0..(M+1)` fold like array dims. The `.len` leaf moved into the shared folder via a new `ctx.lookupPackLen`. - The unknown-type semantic checker no longer walks a value-param position (`Vector(N, …)` / `Vec(N, …)`) as a type name (was reporting "unknown type 'N'"). - The parameterized-type-arg parser and the function-body lookahead (hasFnBodyAfterArrow) accept a const-EXPRESSION in a value position, so `Vector(M + 1, f32)` and `[M + 1]T` parse as a return type too (the latter a pre-existing array-dim sibling that the same heuristic broke). Regressions: examples/1501 (named-const + const-expr lane, direct + alias, 3/4-lane reads), 1502 (runtime lane clean-halts, exit 1, no LLVM crash), 0207 (Vec(N)/Vec(M+1) == Vec(3) instantiation), 0610 (inline-for const bounds). Shared-evaluator unit test extended with the pack-len arm. zig build && zig build test && bash tests/run_examples.sh: 395 passed, 0 failed. --- examples/0207-generics-value-param-const.sx | 29 ++++ .../0610-comptime-inline-for-const-bound.sx | 23 ++++ examples/1501-vectors-const-lane.sx | 35 +++++ .../1502-vectors-runtime-lane-not-const.sx | 16 +++ .../0207-generics-value-param-const.exit | 1 + .../0207-generics-value-param-const.stderr | 1 + .../0207-generics-value-param-const.stdout | 3 + .../0610-comptime-inline-for-const-bound.exit | 1 + ...610-comptime-inline-for-const-bound.stderr | 1 + ...610-comptime-inline-for-const-bound.stdout | 2 + .../expected/1501-vectors-const-lane.exit | 1 + .../expected/1501-vectors-const-lane.stderr | 1 + .../expected/1501-vectors-const-lane.stdout | 4 + .../1502-vectors-runtime-lane-not-const.exit | 1 + ...1502-vectors-runtime-lane-not-const.stderr | 11 ++ ...1502-vectors-runtime-lane-not-const.stdout | 1 + ...named-const-array-dimension-miscompiled.md | 31 +++++ src/ir/lower.zig | 124 ++++++++---------- src/ir/program_index.test.zig | 21 +++ src/ir/program_index.zig | 47 +++++-- src/ir/semantic_diagnostics.zig | 30 ++++- src/ir/type_bridge.zig | 7 + src/parser.zig | 42 ++++-- 23 files changed, 340 insertions(+), 93 deletions(-) create mode 100644 examples/0207-generics-value-param-const.sx create mode 100644 examples/0610-comptime-inline-for-const-bound.sx create mode 100644 examples/1501-vectors-const-lane.sx create mode 100644 examples/1502-vectors-runtime-lane-not-const.sx create mode 100644 examples/expected/0207-generics-value-param-const.exit create mode 100644 examples/expected/0207-generics-value-param-const.stderr create mode 100644 examples/expected/0207-generics-value-param-const.stdout create mode 100644 examples/expected/0610-comptime-inline-for-const-bound.exit create mode 100644 examples/expected/0610-comptime-inline-for-const-bound.stderr create mode 100644 examples/expected/0610-comptime-inline-for-const-bound.stdout create mode 100644 examples/expected/1501-vectors-const-lane.exit create mode 100644 examples/expected/1501-vectors-const-lane.stderr create mode 100644 examples/expected/1501-vectors-const-lane.stdout create mode 100644 examples/expected/1502-vectors-runtime-lane-not-const.exit create mode 100644 examples/expected/1502-vectors-runtime-lane-not-const.stderr create mode 100644 examples/expected/1502-vectors-runtime-lane-not-const.stdout diff --git a/examples/0207-generics-value-param-const.sx b/examples/0207-generics-value-param-const.sx new file mode 100644 index 0000000..46c7fc2 --- /dev/null +++ b/examples/0207-generics-value-param-const.sx @@ -0,0 +1,29 @@ +// A generic value parameter (`$K: u32`) bound from a named const or a +// constant-foldable expression resolves to the SAME monomorphised instantiation +// as the literal form: `Vec(N, f32)` (N a module const) and `Vec(M + 1, f32)` +// (a const expression) are both `Vec(3, f32)`. The struct-copy assignment is the +// proof — it type-checks only because the two spellings name one instantiation. +// +// Regression (issue 0083): the value-param binder hand-rolled an `else => 0` +// switch, so a named-const value arg either fabricated a 0 binding under a wrong +// mangled name or was rejected outright as "unknown type 'N'". It now folds +// through the shared const-int evaluator (`program_index.evalConstIntExpr`). +#import "modules/std.sx"; + +N :: 3; +M :: 2; + +Vec :: struct ($K: u32, $T: Type) { data: [K]T; } + +main :: () { + a : Vec(N, f32) = ---; // named-const value param + a.data[0] = 10.0; a.data[1] = 20.0; a.data[2] = 30.0; + print("named: len={} a0={} a2={}\n", a.data.len, a.data[0], a.data[2]); + + e : Vec(M + 1, f32) = ---; // const-expr value param (M + 1 == 3) + e.data[0] = 1.0; e.data[2] = 9.0; + print("expr: len={} e2={}\n", e.data.len, e.data[2]); + + b : Vec(3, f32) = a; // same instantiation → struct copy type-checks + print("copy: len={} b2={}\n", b.data.len, b.data[2]); +} diff --git a/examples/0610-comptime-inline-for-const-bound.sx b/examples/0610-comptime-inline-for-const-bound.sx new file mode 100644 index 0000000..5dce5a8 --- /dev/null +++ b/examples/0610-comptime-inline-for-const-bound.sx @@ -0,0 +1,23 @@ +// `inline for 0..K` with a named-const or constant-foldable bound unrolls at +// compile time, just like a literal bound. +// +// Regression (issue 0083): the inline-for bound folder (`evalComptimeInt`) only +// handled literals, comptime cursors, and `.len`, so `inline for 0..M` +// (M a module const) and `inline for 0..(M + 1)` (a const expression) both +// failed with "range end is not a compile-time integer". `evalComptimeInt` now +// delegates to the single shared const-int evaluator +// (`program_index.evalConstIntExpr`), so the inline-for bound and an array +// dimension fold the same shapes to the same value. +#import "modules/std.sx"; + +M :: 3; + +main :: () { + s := 0; + inline for 0..M: (i) { s += i; } + print("sum 0..M = {}\n", s); // 0 + 1 + 2 = 3 + + t := 0; + inline for 0..(M + 1): (i) { t += i; } + print("sum 0..(M+1) = {}\n", t); // 0 + 1 + 2 + 3 = 6 +} diff --git a/examples/1501-vectors-const-lane.sx b/examples/1501-vectors-const-lane.sx new file mode 100644 index 0000000..b32fa3a --- /dev/null +++ b/examples/1501-vectors-const-lane.sx @@ -0,0 +1,35 @@ +// A `Vector` lane count from a named const or a constant-foldable expression +// resolves to the SAME layout as a literal lane — DIRECT (param / return type) +// and via a type ALIAS. A 3-lane (named const `N`) and a 4-lane (const expr +// `M + 1`) prove the lane VALUE is folded, not fabricated: reading `.w` requires +// the 4-lane vector to actually have four lanes. +// +// Regression (issue 0083): the stateful Vector lane resolvers hand-rolled an +// `else => 0` switch, so a module-const lane (`Vector(N, f32)`) lowered a 0-lane +// `<0 x float>` and died in LLVM verification ("huge alignment values are +// unsupported"); a const-expr lane (`Vector(M + 1, f32)`) was rejected at parse. +// Both now fold through the single shared const-int evaluator +// (`program_index.evalConstIntExpr`) — the same one the array-dimension path +// uses — so a named-const / const-expr lane is identical to a literal lane. +#import "modules/std.sx"; + +N :: 3; +M :: 3; + +LaneAlias :: Vector(N, f32); // ALIAS: 3-lane via named const. +ExprAlias :: Vector(M + 1, f32); // ALIAS: 4-lane via const expression. + +mk3 :: () -> Vector(N, f32) { .[1.0, 2.0, 3.0] } // DIRECT named-const lane. +mk4 :: () -> Vector(M + 1, f32) { .[1.0, 2.0, 3.0, 4.0] } // DIRECT const-expr lane. + +main :: () { + a := mk3(); + print("direct3: {} {} {}\n", a.x, a.y, a.z); + b := mk4(); + print("direct4: {} {} {} {}\n", b.x, b.y, b.z, b.w); + + c : LaneAlias = .[5.0, 6.0, 7.0]; + print("alias3: {}\n", c.z); + d : ExprAlias = .[5.0, 6.0, 7.0, 8.0]; + print("alias4: {}\n", d.w); +} diff --git a/examples/1502-vectors-runtime-lane-not-const.sx b/examples/1502-vectors-runtime-lane-not-const.sx new file mode 100644 index 0000000..3f14768 --- /dev/null +++ b/examples/1502-vectors-runtime-lane-not-const.sx @@ -0,0 +1,16 @@ +// A `Vector` lane count that is not a compile-time integer (here a runtime +// function call) is a hard error — a clean sx diagnostic with a non-zero exit, +// NOT a fabricated `<0 x float>` lane that crashes LLVM verification. +// +// Regression (issue 0083): the Vector lane resolver hand-rolled an `else => 0` +// switch that silently fabricated a 0-lane vector for a non-const lane. It now +// folds the lane through the shared const-int evaluator and, when that yields no +// compile-time integer, emits this diagnostic and halts the build. +#import "modules/std.sx"; + +lanes :: () -> u32 { return 3; } + +main :: () { + v : Vector(lanes(), f32) = ---; + print("unreachable: {}\n", v.x); +} diff --git a/examples/expected/0207-generics-value-param-const.exit b/examples/expected/0207-generics-value-param-const.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0207-generics-value-param-const.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0207-generics-value-param-const.stderr b/examples/expected/0207-generics-value-param-const.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0207-generics-value-param-const.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0207-generics-value-param-const.stdout b/examples/expected/0207-generics-value-param-const.stdout new file mode 100644 index 0000000..1f3398d --- /dev/null +++ b/examples/expected/0207-generics-value-param-const.stdout @@ -0,0 +1,3 @@ +named: len=3 a0=10.000000 a2=30.000000 +expr: len=3 e2=9.000000 +copy: len=3 b2=30.000000 diff --git a/examples/expected/0610-comptime-inline-for-const-bound.exit b/examples/expected/0610-comptime-inline-for-const-bound.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0610-comptime-inline-for-const-bound.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0610-comptime-inline-for-const-bound.stderr b/examples/expected/0610-comptime-inline-for-const-bound.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0610-comptime-inline-for-const-bound.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0610-comptime-inline-for-const-bound.stdout b/examples/expected/0610-comptime-inline-for-const-bound.stdout new file mode 100644 index 0000000..42d6746 --- /dev/null +++ b/examples/expected/0610-comptime-inline-for-const-bound.stdout @@ -0,0 +1,2 @@ +sum 0..M = 3 +sum 0..(M+1) = 6 diff --git a/examples/expected/1501-vectors-const-lane.exit b/examples/expected/1501-vectors-const-lane.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/1501-vectors-const-lane.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/1501-vectors-const-lane.stderr b/examples/expected/1501-vectors-const-lane.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1501-vectors-const-lane.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/1501-vectors-const-lane.stdout b/examples/expected/1501-vectors-const-lane.stdout new file mode 100644 index 0000000..85baa79 --- /dev/null +++ b/examples/expected/1501-vectors-const-lane.stdout @@ -0,0 +1,4 @@ +direct3: 1.000000 2.000000 3.000000 +direct4: 1.000000 2.000000 3.000000 4.000000 +alias3: 7.000000 +alias4: 8.000000 diff --git a/examples/expected/1502-vectors-runtime-lane-not-const.exit b/examples/expected/1502-vectors-runtime-lane-not-const.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1502-vectors-runtime-lane-not-const.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1502-vectors-runtime-lane-not-const.stderr b/examples/expected/1502-vectors-runtime-lane-not-const.stderr new file mode 100644 index 0000000..d1312de --- /dev/null +++ b/examples/expected/1502-vectors-runtime-lane-not-const.stderr @@ -0,0 +1,11 @@ +error: Vector lane count must be a positive compile-time integer constant + --> examples/1502-vectors-runtime-lane-not-const.sx:14:16 + | +14 | v : Vector(lanes(), f32) = ---; + | ^^^^^^^ + +error: field 'x' not found on type 'unresolved' + --> examples/1502-vectors-runtime-lane-not-const.sx:15:32 + | +15 | print("unreachable: {}\n", v.x); + | ^^^ diff --git a/examples/expected/1502-vectors-runtime-lane-not-const.stdout b/examples/expected/1502-vectors-runtime-lane-not-const.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1502-vectors-runtime-lane-not-const.stdout @@ -0,0 +1 @@ + diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index a2a8292..4a73ced 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -72,6 +72,37 @@ > alias, scalar / string / struct element types); `1129` re-pointed at a genuinely > non-const dimension (`[get()]s64`, a runtime call) so it still proves the > stateless clean-halt. +> +> **Unified comptime-int evaluator (attempt 5).** Attempts 1–4 fixed the array +> *dimension* paths but the SAME length-0 fabrication class survived on the +> siblings that resolve a comptime integer elsewhere: the three Vector lane +> resolvers (`resolveTypeCallWithBindings`, `resolveParameterizedWithBindings`, +> `resolveArrayLiteralType`) and the two generic value-param binders +> (`instantiateGenericStruct`, `instantiateTypeFunction`) each hand-rolled an +> `else => 0` switch, so `Vector(N, f32)` / `Vec(N, f32)` (N a module const) +> fabricated a 0-lane `<0 x float>` (LLVM "huge alignment" abort) or a 0 binding +> under a wrong mangled name; and the `inline for` bound folder (`evalComptimeInt`) +> only knew literals / comptime cursors / `.len`, so `inline for 0..M` failed +> outright. Fix: every one of those sites now routes through the single shared +> `program_index.evalConstIntExpr` — `evalComptimeInt` delegates to it (the pack +> `.len` leaf moved into the shared folder via a new `ctx.lookupPackLen`); the +> Vector lane and value-param resolvers fold through it and emit a clean diagnostic +> + `.unresolved` (never `else => 0`) on a non-const operand. Two enabling fixes +> upstream of resolution: the unknown-type semantic checker no longer walks a +> value-param position (`Vector(N, …)` / `Vec(N, …)`) as a type name (it was +> reporting "unknown type 'N'"); and both the parameterized-type-arg parser and +> the function-body-detection lookahead (`hasFnBodyAfterArrow`) accept a +> const-EXPRESSION in a value position, so `Vector(M + 1, f32)` and `[M + 1]T` +> parse as a return type too (the latter a pre-existing attempt-4 sibling miss). +> Files: `src/ir/program_index.zig` (+`.test.zig`), `src/ir/lower.zig`, +> `src/ir/type_bridge.zig`, `src/ir/semantic_diagnostics.zig`, `src/parser.zig`. +> Regressions: `examples/1501-vectors-const-lane.sx` (named-const + const-expr +> lane, direct + alias, 3- and 4-lane reads), `examples/1502-vectors-runtime-lane- +> not-const.sx` (a runtime lane clean-halts, exit 1, no LLVM crash), +> `examples/0207-generics-value-param-const.sx` (`Vec(N,f32)` / `Vec(M+1,f32)` +> resolve to the same instantiation as `Vec(3,f32)`), +> `examples/0610-comptime-inline-for-const-bound.sx` (`inline for 0..M` and +> `0..(M+1)` unroll). ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 2880b97..7cfca18 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -3984,32 +3984,14 @@ pub const Lowering = struct { return self.builder.constInt(0, .void); } - /// Evaluate a node to a comptime integer: literal, comptime-constant - /// identifier, or `.len` (resolves to the monomorphised arity). + /// Evaluate an `inline for` range bound to a comptime integer. Delegates to + /// the shared `program_index.evalConstIntExpr` — the SAME folder the array + /// dimension / Vector lane / value-param paths use — so a literal, a comptime + /// constant (cursor), a module/generic const (`inline for 0..M`), a + /// `.len` leaf, and any constant-foldable expression over those + /// (`inline for 0..(M + 1)`) all resolve identically. One folder, one answer. fn evalComptimeInt(self: *Lowering, node: *const Node) ?i64 { - switch (node.data) { - .int_literal => |lit| return lit.value, - .identifier => |id| { - if (self.comptime_constants.get(id.name)) |cv| { - switch (cv) { - .int_val => |iv| return iv, - else => return null, - } - } - return null; - }, - .field_access => |fa| { - if (self.pack_param_count) |ppc| { - if (fa.object.data == .identifier and std.mem.eql(u8, fa.field, "len")) { - if (ppc.get(fa.object.data.identifier.name)) |n| { - return @as(i64, @intCast(n)); - } - } - } - return null; - }, - else => return null, - } + return program_index_mod.evalConstIntExpr(node, self); } fn lowerMatch(self: *Lowering, me: *const ast.MatchExpr) Ref { @@ -5428,12 +5410,9 @@ pub const Lowering = struct { }; if (std.mem.eql(u8, callee_name, "Vector")) { if (cl.args.len == 2) { - const length: u32 = switch (cl.args[0].data) { - .int_literal => |lit| @intCast(@as(u64, @bitCast(lit.value))), - else => 0, - }; + const length = self.resolveVectorLane(cl.args[0]) orelse return .unresolved; const elem = self.resolveTypeWithBindings(cl.args[1]); - if (length > 0) return self.module.types.vectorOf(elem, length); + return self.module.types.vectorOf(elem, length); } } // Try as generic struct @@ -11699,6 +11678,18 @@ pub const Lowering = struct { return self.comptimeIntNamed(name); } + /// Pack-length leaf for the shared integer-expression evaluator: a pack + /// name's monomorphised arity (e.g. an `inline for 0..xs.len` bound). + /// Resolves through `pack_param_count`, which is populated when a comptime + /// call binds a pack name. A name with no active pack binding is not a + /// compile-time integer leaf here → null. + pub fn lookupPackLen(self: *Lowering, name: []const u8) ?i64 { + if (self.pack_param_count) |ppc| { + if (ppc.get(name)) |n| return @intCast(n); + } + return null; + } + /// Resolve a name to a compile-time integer across the three const tables. fn comptimeIntNamed(self: *Lowering, name: []const u8) ?i64 { if (self.comptime_constants.get(name)) |cv| switch (cv) { @@ -11828,6 +11819,36 @@ pub const Lowering = struct { return .{ .l = self }; } + /// Resolve a `Vector(N, T)` lane count to a positive compile-time integer + /// through the shared `evalConstIntExpr` folder — so a literal (`Vector(4, + /// f32)`), a module/generic const (`Vector(N, f32)`), and a const expression + /// (`Vector(M + 1, f32)`) all resolve identically. A non-const lane + /// (`Vector(get(), f32)`) or a non-positive one emits a clean diagnostic and + /// returns null; the caller yields `.unresolved` rather than fabricating a + /// `<0 x float>` lane count that crashes LLVM verification. + fn resolveVectorLane(self: *Lowering, lane_node: *const Node) ?u32 { + const v = program_index_mod.evalConstIntExpr(lane_node, self); + if (v == null or v.? < 1) { + if (self.diagnostics) |d| + d.addFmt(.err, lane_node.span, "Vector lane count must be a positive compile-time integer constant", .{}); + return null; + } + return @intCast(v.?); + } + + /// Resolve a generic value-param argument (`$N: u32`) to its compile-time + /// integer through the shared `evalConstIntExpr` folder, so a module/generic + /// const arg (`Vec(N, f32)`) binds the same value — and mangles to the same + /// instantiation — a literal (`Vec(3, f32)`) would. A non-const arg emits a + /// clean diagnostic and returns null; the caller bails rather than + /// fabricating a 0 binding under a wrong mangled name. + fn resolveValueParamArg(self: *Lowering, arg_node: *const Node) ?i64 { + if (program_index_mod.evalConstIntExpr(arg_node, self)) |v| return v; + if (self.diagnostics) |d| + d.addFmt(.err, arg_node.span, "generic value parameter must be a compile-time integer constant", .{}); + return null; + } + /// Resolve a .call node that represents a type constructor (e.g., List(T), Vector(N, T)). fn resolveTypeCallWithBindings(self: *Lowering, cl: *const ast.Call) TypeId { const callee_name: []const u8 = switch (cl.callee.data) { @@ -11837,16 +11858,7 @@ pub const Lowering = struct { }; // Built-in: Vector(N, T) if (std.mem.eql(u8, callee_name, "Vector") and cl.args.len == 2) { - const length: u32 = switch (cl.args[0].data) { - .int_literal => |lit| @intCast(@as(u64, @bitCast(lit.value))), - .identifier => |id| blk: { - if (self.comptime_value_bindings) |cvb| { - if (cvb.get(id.name)) |v| break :blk @intCast(@as(u64, @bitCast(v))); - } - break :blk 0; - }, - else => 0, - }; + const length = self.resolveVectorLane(cl.args[0]) orelse return .unresolved; const elem = self.resolveTypeWithBindings(cl.args[1]); return self.module.types.vectorOf(elem, length); } @@ -11878,24 +11890,7 @@ pub const Lowering = struct { // Vector(N, T) — built-in parameterized type if (std.mem.eql(u8, base_name, "Vector")) { if (pt.args.len == 2) { - // Resolve length: literal, or bound comptime value - const length: u32 = switch (pt.args[0].data) { - .int_literal => |lit| @intCast(@as(u64, @bitCast(lit.value))), - .identifier => |id| blk: { - if (self.comptime_value_bindings) |cvb| { - if (cvb.get(id.name)) |v| break :blk @intCast(@as(u64, @bitCast(v))); - } - break :blk 0; - }, - .type_expr => |te| blk: { - if (self.comptime_value_bindings) |cvb| { - if (cvb.get(te.name)) |v| break :blk @intCast(@as(u64, @bitCast(v))); - } - break :blk 0; - }, - else => 0, - }; - // Resolve element type through bindings + const length = self.resolveVectorLane(pt.args[0]) orelse return .unresolved; const elem = self.resolveTypeWithBindings(pt.args[1]); return table.vectorOf(elem, length); } @@ -11974,11 +11969,8 @@ pub const Lowering = struct { const tname = self.formatTypeName(ty); name_parts.appendSlice(self.alloc, tname) catch {}; } else { - // Value param (e.g., $N: u32) — extract integer - const val: i64 = switch (args[i].data) { - .int_literal => |lit| lit.value, - else => 0, - }; + // Value param (e.g., $N: u32) — fold to a compile-time integer. + const val = self.resolveValueParamArg(args[i]) orelse return .unresolved; cvb.put(tp.name, val) catch {}; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{d}", .{val}) catch "0"; @@ -12071,10 +12063,8 @@ pub const Lowering = struct { const tname = self.formatTypeName(ty); name_parts.appendSlice(self.alloc, tname) catch {}; } else { - const val: i64 = switch (args[i].data) { - .int_literal => |lit| lit.value, - else => 0, - }; + // Value param (e.g., $N: u32) — fold to a compile-time integer. + const val = self.resolveValueParamArg(args[i]) orelse return null; cvb.put(tp.name, val) catch {}; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{d}", .{val}) catch "0"; diff --git a/src/ir/program_index.test.zig b/src/ir/program_index.test.zig index 62aeae4..84a4cb9 100644 --- a/src/ir/program_index.test.zig +++ b/src/ir/program_index.test.zig @@ -106,6 +106,11 @@ const DimCtx = struct { if (std.mem.eql(u8, name, "N")) return 6; return null; } + // `xs` stands in for a pack of arity 3; every other name has no pack length. + pub fn lookupPackLen(_: DimCtx, name: []const u8) ?i64 { + if (std.mem.eql(u8, name, "xs")) return 3; + return null; + } }; fn nLit(v: i64) ast.Node { @@ -120,6 +125,9 @@ fn nBin(op: ast.BinaryOp.Op, l: *ast.Node, r: *ast.Node) ast.Node { fn nNeg(operand: *ast.Node) ast.Node { return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .unary_op = .{ .op = .negate, .operand = operand } } }; } +fn nField(obj: *ast.Node, field: []const u8) ast.Node { + return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .field_access = .{ .object = obj, .field = field } } }; +} test "evalConstIntExpr folds constant-expression array dimensions, halts on non-const" { const eval = pi.evalConstIntExpr; @@ -157,6 +165,19 @@ test "evalConstIntExpr folds constant-expression array dimensions, halts on non- var neg = nNeg(&m); try std.testing.expectEqual(@as(?i64, -4), eval(&neg, ctx)); + // `.len` leaf resolves via `ctx.lookupPackLen` and folds in an + // expression (`xs.len` → 3, `xs.len - 1` → 2). A `.len` on a non-pack name + // and a non-`len` field are not compile-time integer leaves → null. + var xs = nIdent("xs"); + var xslen = nField(&xs, "len"); + var xslen_m1 = nBin(.sub, &xslen, &one); + try std.testing.expectEqual(@as(?i64, 3), eval(&xslen, ctx)); + try std.testing.expectEqual(@as(?i64, 2), eval(&xslen_m1, ctx)); + var zlen = nField(&z, "len"); + var xscap = nField(&xs, "cap"); + try std.testing.expect(eval(&zlen, ctx) == null); + try std.testing.expect(eval(&xscap, ctx) == null); + // Genuinely non-const operand, division by zero, a non-arithmetic operator, // and overflow all yield null → the caller's clean compile-halt (no panic, // no fabricated length). diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index f9a42b8..ce81ea3 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -55,25 +55,44 @@ pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), name: [ return null; } -/// Evaluate a constant-expression array dimension to its integer value. Folds -/// integer `+ - * / %` and unary negate over int literals and named module / -/// comptime consts — recursively, so nested and parenthesised forms -/// (`[M + N - 1]`, `[(M + 1) * 2]`) fold (a grouping `(…)` carries no AST node; -/// the parser returns the inner expression). Leaf names resolve through -/// `ctx.lookupDimName`, so the stateful body-lowering path (which also sees -/// comptime constants and generic `$N` value bindings) and the stateless -/// registration path (module consts only) share THIS expression-folding logic -/// and cannot disagree on a dimension's value — the same unify-or-die rule that -/// keeps an array laid out via a type alias identical to the direct form -/// (issue 0083). Returns null when any operand is not a compile-time integer (a -/// runtime value, a non-comptime call, an unbound name) or the arithmetic -/// overflows / divides by zero: the caller then emits the clean compile-halting -/// diagnostic, never a fabricated length. +/// Evaluate a constant integer expression to its value. THE single +/// integer-expression folder for the compiler — array dimensions (`[N]T`, +/// `[M + 1]T`), Vector lane counts (`Vector(N, f32)`), generic value-param +/// args (`Vec(N, f32)`), and `inline for 0..M` bounds all route here so they +/// cannot disagree on what a given expression evaluates to (the issue-0083 +/// two-resolver class of bug). Folds integer `+ - * / %` and unary negate over +/// int literals and named module / comptime consts — recursively, so nested and +/// parenthesised forms (`[M + N - 1]`, `[(M + 1) * 2]`) fold (a grouping `(…)` +/// carries no AST node; the parser returns the inner expression). +/// +/// Leaves resolve through the ctx, so each call site shares the SAME folding +/// logic while contributing its own bindings: +/// - `ctx.lookupDimName(name)` — a name bound to a compile-time integer. The +/// stateful body-lowering ctx sees comptime constants, generic `$N` value +/// bindings, and module consts; the stateless registration ctx sees module +/// consts only. +/// - `ctx.lookupPackLen(name)` — a `.len` leaf → the pack's +/// monomorphised arity. Only the body-lowering ctx knows pack arities; the +/// stateless ctx returns null. +/// +/// Returns null when any operand is not a compile-time integer (a runtime value, +/// a non-comptime call, an unbound name) or the arithmetic overflows / divides +/// by zero: the caller then emits the clean compile-halting diagnostic, never a +/// fabricated length / lane count / value-param. pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 { return switch (node.data) { .int_literal => |lit| lit.value, .identifier => |id| ctx.lookupDimName(id.name), .type_expr => |te| ctx.lookupDimName(te.name), + .field_access => |fa| blk: { + // `.len` resolves to the monomorphised arity (e.g. an + // `inline for 0..xs.len` bound). Any other field access is not a + // compile-time integer leaf. + if (fa.object.data == .identifier and std.mem.eql(u8, fa.field, "len")) { + break :blk ctx.lookupPackLen(fa.object.data.identifier.name); + } + break :blk null; + }, .unary_op => |u| switch (u.op) { .negate => { const v = evalConstIntExpr(u.operand, ctx) orelse return null; diff --git a/src/ir/semantic_diagnostics.zig b/src/ir/semantic_diagnostics.zig index 33e297d..c52b4cd 100644 --- a/src/ir/semantic_diagnostics.zig +++ b/src/ir/semantic_diagnostics.zig @@ -612,6 +612,20 @@ pub const UnknownTypeChecker = struct { } } + /// True when arg `i` of a parameterized type `base(...)` is a VALUE + /// parameter (a compile-time integer such as a `Vector` lane count or a + /// generic `$N: u32` arg), not a type. Such a position must be skipped by + /// the unknown-type walk: a module-const arg (`Vector(N, f32)`) is a value, + /// not a type name. `Vector`'s arg 0 is always its lane count; a generic + /// struct template's value-param positions come from its declared params. + fn isValueParamPosition(self: UnknownTypeChecker, base: []const u8, i: usize) bool { + if (std.mem.eql(u8, base, "Vector")) return i == 0; + if (self.index.struct_template_map.get(base)) |tmpl| { + if (i < tmpl.type_params.len) return !tmpl.type_params[i].is_type_param; + } + return false; + } + /// Recurse a type-annotation node to its leaf names, reporting any unknown. fn checkTypeNodeForUnknown( self: UnknownTypeChecker, @@ -643,8 +657,20 @@ pub const UnknownTypeChecker = struct { if (ct.return_type) |rt| self.checkTypeNodeForUnknown(rt, declared, in_scope, type_vals); }, // Builtin constructors (Vector) and generic templates resolve the - // base name specially; just check the type args. - .parameterized_type_expr => |pt| for (pt.args) |a| self.checkTypeNodeForUnknown(a, declared, in_scope, type_vals), + // base name specially; check only the TYPE args. A value-param + // position (a `Vector` lane count, or a generic `$N: u32` arg) holds + // a compile-time integer — `Vector(N, f32)` / `Vec(N, f32)` with `N` + // a module const — not a type name, so it must not be walked as one + // (it would falsely report "unknown type 'N'"). The lowering + // resolvers fold the value and emit the precise diagnostic if it + // isn't a compile-time integer. + .parameterized_type_expr => |pt| { + const base = if (std.mem.lastIndexOfScalar(u8, pt.name, '.')) |dot| pt.name[dot + 1 ..] else pt.name; + for (pt.args, 0..) |a, i| { + if (self.isValueParamPosition(base, i)) continue; + self.checkTypeNodeForUnknown(a, declared, in_scope, type_vals); + } + }, else => {}, } } diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index 8f07f97..4ef7398 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -68,6 +68,13 @@ const StatelessInner = struct { const consts = self.consts orelse return null; return program_index_mod.moduleConstInt(consts, name); } + /// Pack-length leaf for the shared integer-expression evaluator. The + /// registration-time path has no pack-arity information (packs are bound + /// during body lowering), so a `.len` dimension is never a + /// compile-time integer here → null → the clean unresolved-dim diagnostic. + pub fn lookupPackLen(_: StatelessInner, _: []const u8) ?i64 { + return null; + } }; // ── AST Node → TypeId ─────────────────────────────────────────────────── diff --git a/src/parser.zig b/src/parser.zig index 7113e13..1aa35d3 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -723,7 +723,25 @@ pub const Parser = struct { if (args.items.len > 0) { try self.expect(.comma); } - // Args can be int literals (for lengths) or type expressions + // Pack-spread type arg: `Combined($R, ..sources.T)`. + if (self.current.tag == .dot_dot) { + const sp_start = self.current.loc.start; + self.advance(); // skip '..' + const operand = try self.parseTypeExpr(); + try args.append(self.allocator, try self.createNode(sp_start, .{ .spread_expr = .{ .operand = operand } })); + continue; + } + // An arg is either a TYPE (`f32`, `*T`, `[]u8`, `List(T)`) or a + // compile-time integer expression in a value position — a + // `Vector` lane count or a generic `$N: u32` arg: `Vector(N, f32)`, + // `Vector(M + 1, f32)`. Parse the primary as a literal / type, + // then continue as a const-int expression iff an arithmetic + // operator follows. A complete type arg is always followed by + // `,` / `)`, so `parseBinaryRhs` is a no-op for plain types and + // the continuation is unambiguous; `Prec.additive` bounds it to + // `+ - * / %`. The shared evaluator folds the expression; a + // non-const value position is diagnosed during lowering. + var arg: *Node = undefined; if (self.current.tag == .int_literal) { const arg_start = self.current.loc.start; const text = self.tokenSlice(self.current); @@ -738,16 +756,12 @@ pub const Parser = struct { return self.fail("invalid integer literal in type argument"); }; self.advance(); - try args.append(self.allocator, try self.createNode(arg_start, .{ .int_literal = .{ .value = value } })); - } else if (self.current.tag == .dot_dot) { - // Pack-spread type arg: `Combined($R, ..sources.T)`. - const sp_start = self.current.loc.start; - self.advance(); // skip '..' - const operand = try self.parseTypeExpr(); - try args.append(self.allocator, try self.createNode(sp_start, .{ .spread_expr = .{ .operand = operand } })); + arg = try self.createNode(arg_start, .{ .int_literal = .{ .value = value } }); } else { - try args.append(self.allocator, try self.parseTypeExpr()); + arg = try self.parseTypeExpr(); } + arg = try self.parseBinaryRhs(arg, Prec.additive); + try args.append(self.allocator, arg); } try self.expect(.r_paren); return try self.createNode(start, .{ .parameterized_type_expr = .{ @@ -3501,7 +3515,15 @@ pub const Parser = struct { self.current.tag == .l_bracket or self.current.tag == .r_bracket or self.current.tag == .l_paren or self.current.tag == .r_paren or self.current.tag == .comma or self.current.tag == .int_literal or - self.current.tag == .star or self.current.tag == .question or + // Arithmetic operators appear in a const-expression dimension / + // lane / value-param in a return type: `-> [N + 1]f32`, + // `-> Vector(N + 1, f32)`. They must be skipped while scanning + // for the body brace, else the decl is misread as a bodyless + // function-type alias and the `{` body errors as "expected ';'". + // (`.star` doubles as the pointer sigil and is already listed.) + self.current.tag == .star or self.current.tag == .slash or + self.current.tag == .percent or self.current.tag == .plus or + self.current.tag == .minus or self.current.tag == .question or self.current.tag == .bang or self.current.tag == .colon or self.current.tag == .arrow) { From 7238eea08482f56a9006513c0d48a05b9c8fd3ae Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 11:32:31 +0300 Subject: [PATCH 06/13] =?UTF-8?q?docs(issues):=20file=200086=20=E2=80=94?= =?UTF-8?q?=20Vector=20lane=20store=20panics=20(discovered,=20pre-existing?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While fixing 0083 (attempt 5) noticed a distinct, pre-existing bug: writing to a Vector component (`v.x = 1.0`) aborts with "unresolved type reached LLVM emission" in emitStore. Reading a lane works; a literal lane count triggers it, so it is NOT the lane-count class. Confirmed reproducible on the pristine pre-attempt-5 compiler (not introduced by the lane-count fix). The standard vector idiom (`.[…]` construction + component reads / arithmetic, examples/1500) is unaffected. Filed for a separate session; not worked around here. --- ...0086-vector-lane-store-unresolved-panic.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 issues/0086-vector-lane-store-unresolved-panic.md diff --git a/issues/0086-vector-lane-store-unresolved-panic.md b/issues/0086-vector-lane-store-unresolved-panic.md new file mode 100644 index 0000000..9666fac --- /dev/null +++ b/issues/0086-vector-lane-store-unresolved-panic.md @@ -0,0 +1,56 @@ +# 0086 — writing to a Vector lane (`v.x = …`) panics with "unresolved type reached LLVM emission" + +## Symptom +Assigning to a component of a `Vector` local — `v.x = 1.0` (also `.y` / `.z` / +`.w`) — aborts the compiler with the internal panic: + +``` +thread … panic: unresolved type reached LLVM emission — a type resolution +failure was not diagnosed/aborted + src/backend/llvm/types.zig:175 toLLVMTypeInfo (.unresolved arm @panic) + src/backend/llvm/ops.zig:358 emitStore (.pointer => toLLVMType(p.pointee)) +``` + +READING a lane (`x := v.x`) is fine; only the STORE side hits it. The init form +(`= ---` undefined vs `= .[…]` literal) does not matter — both panic once a lane +is written. A literal lane count (`Vector(3, f32)`) triggers it, so this is NOT +the lane-count resolution class (issue 0083); it is a distinct bug in the +vector-lane **store** path, where the store's pointee type resolves to the +`.unresolved` sentinel instead of the lane element type. + +Discovered while fixing issue 0083 (attempt 5). It is pre-existing and orthogonal +— confirmed by reproducing on the pristine pre-0083-attempt-5 compiler — so it was +NOT introduced by the lane-count fix. The standard vector idiom (construct via a +`.[…]` literal / a constructor function returning `.[…]`, then read components or +use vector arithmetic, as in `examples/1500-vectors-vector-math.sx`) is +unaffected; only component ASSIGNMENT is broken. + +## Reproduction +```sx +#import "modules/std.sx"; +main :: () { + v : Vector(3, f32) = .[0.0, 0.0, 0.0]; + v.x = 1.0; // panic here + print("x={}\n", v.x); +} +``` +`./zig-out/bin/sx run` panics. Removing the `v.x = 1.0` line (read-only) prints +`x=0.000000` and exits 0. + +## Investigation prompt +A store to a `Vector` lane (`v.x = …`) lowers a pointer-to-lane whose pointee +type reaches LLVM as `.unresolved`, so `emitStore` +(`src/backend/llvm/ops.zig:358`, the `.pointer => toLLVMType(p.pointee)` arm) +hits the `.unresolved` tripwire panic in +`src/backend/llvm/types.zig:175`. The lane READ path computes the lane element +type correctly, so compare the lvalue/store lowering for a vector-component +assignment against the rvalue/load path — the component-write path is likely +building the lane pointer's pointee from a vector `.x`/`.y`/`.z`/`.w` field +resolution that returns `.unresolved` (or a vector field-access that resolves the +element type on load but not on store). Find where a `Vector` swizzle/component +assignment lowers its destination pointer (grep for vector component handling in +`lower.zig` assignment lowering and in the LLVM `emitStore` GEP path) and resolve +the lane element type there the same way the load path does. Verify with the +repro (expect `x=1.000000`) plus a `.[…]`-init write and a write to each of +`.x/.y/.z/.w` on a 4-lane vector, then `zig build && zig build test && bash +tests/run_examples.sh` green. From efc09699e86c2979ee67c46ba27a41e99e59a752 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 12:13:45 +0300 Subject: [PATCH 07/13] fix(ir): value-param type functions + range-checked dim/lane fold (0083, 0087) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two remaining siblings in F0.4's comptime-int path. 1. Type-returning function with a value param used as a TYPE annotation (`b : Make(N, s64)` where `Make :: ($K: u32, $T: Type) -> Type`): - `isValueParamPosition` (semantic_diagnostics) now also skips a value param of a `fn_ast_map` type-returning function, so `N` is not walked as the type name "N" ("unknown type 'N'"). - `resolveParameterizedWithBindings` routes a type-returning-function name to `instantiateTypeFunction` (the `.call` path already did). - `instantiateTypeFunction` resolves a general return-type expression (`return [K]T`) with bindings active — not just struct/union returns. `Make(N, s64)`, `Make(M + 1, s64)`, `Make(3, s64)` all resolve to one `[3]s64`. 2. Oversized dim/lane fold panicked the compiler (0087): an array dim / Vector lane folded to a valid i64 (5e9) then narrowed to u32 with an unchecked `@intCast`. New single gate `program_index.foldDimU32` folds via `evalConstIntExpr` then range-checks `[min, maxInt(u32)]`; the three narrowing sites (resolveArrayLen stateful + stateless, resolveVectorLane) all route through it and emit a clean diagnostic + halt instead of panicking. Value-param args stay i64 until used as a dim/lane, where the same gate checks them. Regressions: examples/0208 (value-param type function), examples/1130 (oversized array dim clean halt), examples/1503 (oversized Vector lane clean halt). Marks issue 0087 RESOLVED. Gate: zig build, zig build test, bash tests/run_examples.sh — 398 passed, 0 failed, 0 timed out. --- ...0208-generics-value-param-type-function.sx | 32 +++++ ...130-diagnostics-array-dim-oversized-u32.sx | 15 +++ .../1503-vectors-oversized-lane-not-u32.sx | 15 +++ ...08-generics-value-param-type-function.exit | 1 + ...-generics-value-param-type-function.stderr | 1 + ...-generics-value-param-type-function.stdout | 3 + ...0-diagnostics-array-dim-oversized-u32.exit | 1 + ...diagnostics-array-dim-oversized-u32.stderr | 5 + ...diagnostics-array-dim-oversized-u32.stdout | 1 + .../1503-vectors-oversized-lane-not-u32.exit | 1 + ...1503-vectors-oversized-lane-not-u32.stderr | 11 ++ ...1503-vectors-oversized-lane-not-u32.stdout | 1 + ...named-const-array-dimension-miscompiled.md | 19 +++ ...oversized-comptime-int-dimension-panics.md | 64 +++++++++ src/ir/lower.zig | 126 ++++++++++++------ src/ir/program_index.zig | 31 +++++ src/ir/semantic_diagnostics.zig | 18 ++- src/ir/type_bridge.zig | 28 ++-- 18 files changed, 321 insertions(+), 52 deletions(-) create mode 100644 examples/0208-generics-value-param-type-function.sx create mode 100644 examples/1130-diagnostics-array-dim-oversized-u32.sx create mode 100644 examples/1503-vectors-oversized-lane-not-u32.sx create mode 100644 examples/expected/0208-generics-value-param-type-function.exit create mode 100644 examples/expected/0208-generics-value-param-type-function.stderr create mode 100644 examples/expected/0208-generics-value-param-type-function.stdout create mode 100644 examples/expected/1130-diagnostics-array-dim-oversized-u32.exit create mode 100644 examples/expected/1130-diagnostics-array-dim-oversized-u32.stderr create mode 100644 examples/expected/1130-diagnostics-array-dim-oversized-u32.stdout create mode 100644 examples/expected/1503-vectors-oversized-lane-not-u32.exit create mode 100644 examples/expected/1503-vectors-oversized-lane-not-u32.stderr create mode 100644 examples/expected/1503-vectors-oversized-lane-not-u32.stdout create mode 100644 issues/0087-oversized-comptime-int-dimension-panics.md diff --git a/examples/0208-generics-value-param-type-function.sx b/examples/0208-generics-value-param-type-function.sx new file mode 100644 index 0000000..fde668a --- /dev/null +++ b/examples/0208-generics-value-param-type-function.sx @@ -0,0 +1,32 @@ +// A type-RETURNING function with a value parameter (`$K: u32`) used as a TYPE +// annotation: `b : Make(N, s64)` where `Make :: ($K, $T) -> Type { return [K]T; }`. +// A named-const value arg (`Make(N, s64)`), a const-expression value arg +// (`Make(M + 1, s64)`), and the literal form (`Make(3, s64)`) all instantiate to +// the SAME type — the array copy `b : Make(3, s64) = a` type-checks only because +// the three spellings name one `[3]s64`. +// +// Regression (issue 0083 / F0.4 attempt 6): the unknown-type checker walked the +// value-param position as a type name ("unknown type 'N'"), and the +// parameterized-type-annotation path never routed to `instantiateTypeFunction`, +// nor did that binder resolve a non-struct/union return shape (`return [K]T`). +// The value arg now folds through the shared const-int evaluator and the type +// function resolves its general return-type expression with bindings active. +#import "modules/std.sx"; + +N :: 3; +M :: 2; + +Make :: ($K: u32, $T: Type) -> Type { return [K]T; } + +main :: () { + a : Make(N, s64) = ---; // named-const value param + a[0] = 10; a[1] = 20; a[2] = 30; + print("named: len={} a0={} a2={}\n", a.len, a[0], a[2]); + + e : Make(M + 1, s64) = ---; // const-expr value param (M + 1 == 3) + e[0] = 1; e[2] = 9; + print("expr: len={} e2={}\n", e.len, e[2]); + + b : Make(3, s64) = a; // same instantiation → array copy type-checks + print("copy: len={} b2={}\n", b.len, b[2]); +} diff --git a/examples/1130-diagnostics-array-dim-oversized-u32.sx b/examples/1130-diagnostics-array-dim-oversized-u32.sx new file mode 100644 index 0000000..9ff8eae --- /dev/null +++ b/examples/1130-diagnostics-array-dim-oversized-u32.sx @@ -0,0 +1,15 @@ +// An array dimension that folds to a valid compile-time integer but exceeds a +// `u32` (`[5_000_000_000]s64`) is a hard error — a clean sx diagnostic with a +// non-zero exit, NOT a compiler panic. +// +// Regression (issue 0087 / F0.4 attempt 6): the dimension folded to a valid i64 +// (5e9) and was then narrowed with an unchecked `@intCast` to u32, aborting the +// COMPILER with "integer does not fit in destination type". Every dim/lane fold +// now narrows through the single range-checked `program_index.foldDimU32`, which +// reports an out-of-u32-range dimension as this diagnostic and halts the build. +#import "modules/std.sx"; + +main :: () { + a : [5000000000]s64 = ---; + print("unreachable: {}\n", a.len); +} diff --git a/examples/1503-vectors-oversized-lane-not-u32.sx b/examples/1503-vectors-oversized-lane-not-u32.sx new file mode 100644 index 0000000..1c6979e --- /dev/null +++ b/examples/1503-vectors-oversized-lane-not-u32.sx @@ -0,0 +1,15 @@ +// A `Vector` lane count that folds to a valid compile-time integer but exceeds a +// `u32` (`Vector(5_000_000_000, f32)`) is a hard error — a clean sx diagnostic +// with a non-zero exit, NOT a compiler panic. +// +// Regression (issue 0087 / F0.4 attempt 6): the lane folded to a valid i64 (5e9) +// and was then narrowed with an unchecked `@intCast` to u32, aborting the +// COMPILER with "integer does not fit in destination type". The lane now narrows +// through the single range-checked `program_index.foldDimU32`, which reports an +// out-of-u32-range lane as this diagnostic and halts the build. +#import "modules/std.sx"; + +main :: () { + v : Vector(5000000000, f32) = ---; + print("unreachable: {}\n", v.x); +} diff --git a/examples/expected/0208-generics-value-param-type-function.exit b/examples/expected/0208-generics-value-param-type-function.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0208-generics-value-param-type-function.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0208-generics-value-param-type-function.stderr b/examples/expected/0208-generics-value-param-type-function.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0208-generics-value-param-type-function.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0208-generics-value-param-type-function.stdout b/examples/expected/0208-generics-value-param-type-function.stdout new file mode 100644 index 0000000..a8fa435 --- /dev/null +++ b/examples/expected/0208-generics-value-param-type-function.stdout @@ -0,0 +1,3 @@ +named: len=3 a0=10 a2=30 +expr: len=3 e2=9 +copy: len=3 b2=30 diff --git a/examples/expected/1130-diagnostics-array-dim-oversized-u32.exit b/examples/expected/1130-diagnostics-array-dim-oversized-u32.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1130-diagnostics-array-dim-oversized-u32.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1130-diagnostics-array-dim-oversized-u32.stderr b/examples/expected/1130-diagnostics-array-dim-oversized-u32.stderr new file mode 100644 index 0000000..68eda08 --- /dev/null +++ b/examples/expected/1130-diagnostics-array-dim-oversized-u32.stderr @@ -0,0 +1,5 @@ +error: array dimension 5000000000 does not fit in u32 + --> examples/1130-diagnostics-array-dim-oversized-u32.sx:13:10 + | +13 | a : [5000000000]s64 = ---; + | ^^^^^^^^^^ diff --git a/examples/expected/1130-diagnostics-array-dim-oversized-u32.stdout b/examples/expected/1130-diagnostics-array-dim-oversized-u32.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1130-diagnostics-array-dim-oversized-u32.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1503-vectors-oversized-lane-not-u32.exit b/examples/expected/1503-vectors-oversized-lane-not-u32.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1503-vectors-oversized-lane-not-u32.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1503-vectors-oversized-lane-not-u32.stderr b/examples/expected/1503-vectors-oversized-lane-not-u32.stderr new file mode 100644 index 0000000..45034c4 --- /dev/null +++ b/examples/expected/1503-vectors-oversized-lane-not-u32.stderr @@ -0,0 +1,11 @@ +error: Vector lane count 5000000000 does not fit in u32 + --> examples/1503-vectors-oversized-lane-not-u32.sx:13:16 + | +13 | v : Vector(5000000000, f32) = ---; + | ^^^^^^^^^^ + +error: field 'x' not found on type 'unresolved' + --> examples/1503-vectors-oversized-lane-not-u32.sx:14:32 + | +14 | print("unreachable: {}\n", v.x); + | ^^^ diff --git a/examples/expected/1503-vectors-oversized-lane-not-u32.stdout b/examples/expected/1503-vectors-oversized-lane-not-u32.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1503-vectors-oversized-lane-not-u32.stdout @@ -0,0 +1 @@ + diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index 4a73ced..fe789a4 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -103,6 +103,25 @@ > resolve to the same instantiation as `Vec(3,f32)`), > `examples/0610-comptime-inline-for-const-bound.sx` (`inline for 0..M` and > `0..(M+1)` unroll). +> +> **Value-param type functions + oversized guard (attempt 6).** Two remaining +> siblings in the comptime-int path. (1) A type-RETURNING function with a value +> param used as a TYPE annotation (`b : Make(N, s64)` where `Make :: ($K: u32, +> $T: Type) -> Type { return [K]T; }`) was rejected "unknown type 'N'" because +> the unknown-type checker walked the value-param position as a type name, AND the +> parameterized-type-annotation path never routed to `instantiateTypeFunction` +> (only the `.call` path did), nor did that binder resolve a non-struct/union +> return shape. Fix: `isValueParamPosition` (semantic_diagnostics.zig) now also +> skips a value param of a `fn_ast_map` type-returning function (mirroring the +> binder's value/type classification); `resolveParameterizedWithBindings` routes +> a type-returning-function name to `instantiateTypeFunction`; and that binder +> resolves a general return-type expression (`return [K]T`) with bindings active. +> `Make(N, s64)`, `Make(M + 1, s64)`, and `Make(3, s64)` now resolve to one +> `[3]s64`. (2) Oversized dim/lane folds (`[5_000_000_000]`) panicked the +> compiler — fixed under issue 0087 via the shared range-checked +> `program_index.foldDimU32` gate. Files: `src/ir/semantic_diagnostics.zig`, +> `src/ir/lower.zig`, `src/ir/program_index.zig`, `src/ir/type_bridge.zig`. +> Regression: `examples/0208-generics-value-param-type-function.sx`. ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/issues/0087-oversized-comptime-int-dimension-panics.md b/issues/0087-oversized-comptime-int-dimension-panics.md new file mode 100644 index 0000000..52f1505 --- /dev/null +++ b/issues/0087-oversized-comptime-int-dimension-panics.md @@ -0,0 +1,64 @@ +# 0087 - oversized compile-time integer in type dimension/lane panics + +> **RESOLVED.** Root cause: an array dimension / Vector lane folded to a valid +> `i64` (e.g. `5_000_000_000`) was then narrowed to `u32` with an unchecked +> `@intCast` at three sites (`Lowering.resolveArrayLen` lower.zig:11656, +> `Lowering.resolveVectorLane` lower.zig:11836, `StatelessInner.resolveArrayLen` +> type_bridge.zig:58), aborting the COMPILER with "integer does not fit in +> destination type" — the fold was correct, the narrowing was not. Fix: a single +> range-checked fold-to-u32 gate, `program_index.foldDimU32(node, ctx, min)`, +> folds via `evalConstIntExpr` then checks `[min, maxInt(u32)]` and returns a +> tagged `DimU32` (`.ok` / `.not_const` / `.below_min` / `.too_large`). Every +> dim/lane narrowing now routes through it — no call site does a bare `@intCast`, +> so an out-of-u32-range dim/lane surfaces a clean diagnostic ("array dimension N +> does not fit in u32" / "Vector lane count N does not fit in u32") and halts the +> build (exit 1) instead of panicking. Value-param args stay `i64` until used as +> a dim/lane, where the same gate checks them. Files: `src/ir/program_index.zig` +> (`DimU32` + `foldDimU32`), `src/ir/lower.zig`, `src/ir/type_bridge.zig`. +> Regressions: `examples/1130-diagnostics-array-dim-oversized-u32.sx` (oversized +> array dim → clean halt) and `examples/1503-vectors-oversized-lane-not-u32.sx` +> (oversized Vector lane → clean halt). + +## Symptom +An oversized compile-time integer used where the compiler expects a `u32` +array dimension or Vector lane count panics inside the compiler instead of +emitting a source diagnostic. + +Observed: `@intCast` panics with "integer does not fit in destination type" in +`Lowering.resolveArrayLen` / `Lowering.resolveVectorLane`. + +Expected: compile halts with a normal diagnostic such as "array dimension does +not fit in u32" / "Vector lane count must fit in u32", and no compiler panic. + +## Reproduction +```sx +#import "modules/std.sx"; + +main :: () { + a : [5000000000]s64 = ---; + print("{}\n", a.len); +} +``` + +Vector lane sibling: + +```sx +#import "modules/std.sx"; + +main :: () { + v : Vector(5000000000, f32) = .[]; + print("{}\n", v); +} +``` + +## Investigation prompt +Fix oversized compile-time integer handling for fixed-array dimensions and +Vector lane counts. Suspected area: `src/ir/lower.zig` +`Lowering.resolveArrayLen` and `Lowering.resolveVectorLane`, plus the stateless +adapter in `src/ir/type_bridge.zig` `StatelessInner.resolveArrayLen`. These +functions fold to `i64` through `program_index.evalConstIntExpr`, then cast to +`u32` with `@intCast`; values greater than `std.math.maxInt(u32)` panic in the +compiler. The fix likely needs an explicit range check before every `u32` cast, +with a diagnostic on the stateful path and null / `.unresolved` propagation on +the stateless path. Verify both repros exit 1 with source diagnostics and no +panic, then run `zig build && zig build test && bash tests/run_examples.sh`. diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 7cfca18..1d1995a 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -11639,37 +11639,36 @@ pub const Lowering = struct { /// Fixed-array dimension hook for `TypeResolver.resolveCompound`. A literal /// `[16]T` and a named-const `N :: 16; [N]T` must resolve to the SAME length: - /// the dimension is a compile-time integer, looked up in the comptime / value - /// / module-const tables the stateful lowering owns. A dimension that isn't a - /// compile-time integer is a hard error: emit a diagnostic so the driver - /// aborts (`hasErrors()`), then return a harmless `0` so body lowering - /// finishes without touching the `.unresolved` sentinel (which would `@panic` - /// in `sizeOf` mid-lowering, before the diagnostic surfaces). The diagnostic — - /// not the returned length — is what guarantees no garbage ships (issue 0083). + /// the dimension folds to a compile-time integer (looked up in the comptime / + /// value / module-const tables the stateful lowering owns) and is narrowed to + /// `u32` through the single range-checked `program_index.foldDimU32` — never a + /// bare `@intCast`, so an oversized-but-valid `i64` dim (`[5_000_000_000]`) + /// diagnoses instead of panicking the compiler (issue 0087). A dimension that + /// isn't a compile-time integer (or doesn't fit a `u32`) is a hard error: + /// emit a diagnostic so the driver aborts (`hasErrors()`), then return a + /// harmless `0` so body lowering finishes without touching the `.unresolved` + /// sentinel (which would `@panic` in `sizeOf` mid-lowering, before the + /// diagnostic surfaces). The diagnostic — not the returned length — is what + /// guarantees no garbage ships (issue 0083). pub fn resolveArrayLen(self: *Lowering, len_node: *const Node) ?u32 { - if (self.comptimeArrayDim(len_node)) |n| { - if (n < 0) { + switch (program_index_mod.foldDimU32(len_node, self, 0)) { + .ok => |n| return n, + .below_min => |v| { if (self.diagnostics) |d| - d.addFmt(.err, len_node.span, "array dimension must be non-negative, got {}", .{n}); + d.addFmt(.err, len_node.span, "array dimension must be non-negative, got {}", .{v}); return 0; - } - return @intCast(n); + }, + .too_large => |v| { + if (self.diagnostics) |d| + d.addFmt(.err, len_node.span, "array dimension {} does not fit in u32", .{v}); + return 0; + }, + .not_const => { + if (self.diagnostics) |d| + d.addFmt(.err, len_node.span, "array dimension must be a compile-time integer constant", .{}); + return 0; + }, } - if (self.diagnostics) |d| - d.addFmt(.err, len_node.span, "array dimension must be a compile-time integer constant", .{}); - return 0; - } - - /// Evaluate a fixed-array dimension to a compile-time integer: a literal, a - /// name bound to an integer (comptime-constant `OS`/loop cursors, generic - /// `$N` value, or module-global const `N :: 16`), or a constant-foldable - /// expression over those (`[M + 1]`, `[(M + 1) * 2]`). Delegates the - /// expression folding to the shared `program_index.evalConstIntExpr` so this - /// body-lowering path and the stateless registration path cannot diverge on - /// a dimension's value. Returns null when the dimension isn't a compile-time - /// integer. - fn comptimeArrayDim(self: *Lowering, node: *const Node) ?i64 { - return program_index_mod.evalConstIntExpr(node, self); } /// Leaf-name lookup for the shared dimension evaluator: a name bound to a @@ -11820,20 +11819,28 @@ pub const Lowering = struct { } /// Resolve a `Vector(N, T)` lane count to a positive compile-time integer - /// through the shared `evalConstIntExpr` folder — so a literal (`Vector(4, - /// f32)`), a module/generic const (`Vector(N, f32)`), and a const expression - /// (`Vector(M + 1, f32)`) all resolve identically. A non-const lane - /// (`Vector(get(), f32)`) or a non-positive one emits a clean diagnostic and - /// returns null; the caller yields `.unresolved` rather than fabricating a - /// `<0 x float>` lane count that crashes LLVM verification. + /// through the shared `program_index.foldDimU32` folder (min 1) — so a literal + /// (`Vector(4, f32)`), a module/generic const (`Vector(N, f32)`), and a const + /// expression (`Vector(M + 1, f32)`) all resolve identically, and the i64→u32 + /// narrowing is range-checked (an oversized lane diagnoses instead of + /// panicking — issue 0087). A non-const lane (`Vector(get(), f32)`) or a + /// non-positive one emits a clean diagnostic and returns null; the caller + /// yields `.unresolved` rather than fabricating a `<0 x float>` lane count + /// that crashes LLVM verification. fn resolveVectorLane(self: *Lowering, lane_node: *const Node) ?u32 { - const v = program_index_mod.evalConstIntExpr(lane_node, self); - if (v == null or v.? < 1) { - if (self.diagnostics) |d| - d.addFmt(.err, lane_node.span, "Vector lane count must be a positive compile-time integer constant", .{}); - return null; + switch (program_index_mod.foldDimU32(lane_node, self, 1)) { + .ok => |n| return n, + .too_large => |v| { + if (self.diagnostics) |d| + d.addFmt(.err, lane_node.span, "Vector lane count {} does not fit in u32", .{v}); + return null; + }, + .not_const, .below_min => { + if (self.diagnostics) |d| + d.addFmt(.err, lane_node.span, "Vector lane count must be a positive compile-time integer constant", .{}); + return null; + }, } - return @intCast(v.?); } /// Resolve a generic value-param argument (`$N: u32`) to its compile-time @@ -11909,6 +11916,20 @@ pub const Lowering = struct { } } + // User-defined type-returning function used as a TYPE annotation + // (`b : Make(N, s64)` where `Make :: ($K: u32, $T: Type) -> Type`). The + // `.call`-node path (`resolveTypeCallWithBindings`) already routes here; + // a `parameterized_type_expr` must too, or the function name falls through + // to the empty-struct stub below and `b.field` / `b.len` fails. + const resolved_name = if (self.scope) |scope| (scope.lookupFn(base_name) orelse base_name) else base_name; + if (self.program_index.fn_ast_map.get(resolved_name)) |fd| { + if (fd.type_params.len > 0) { + if (self.instantiateTypeFunction(base_name, base_name, fd, pt.args)) |ty| { + return ty; + } + } + } + // Fallback: register as named type placeholder const name_id = table.internString(pt.name); return table.intern(.{ .@"struct" = .{ .name = name_id, .fields = &.{} } }); @@ -12141,9 +12162,36 @@ pub const Lowering = struct { return self.instantiateTypeUnion(if (has_alias) alias_name else mangled_name, mangled_name, &enum_decl); } + // General case: the body returns a TYPE EXPRESSION that is not an inline + // struct/union/enum — `return [K]T`, `Vector(K, T)`, `*T`, an alias, etc. + // Resolve it with the value/type bindings active (so `[K]T` folds K to a + // compile-time integer). The result is interned structurally, so + // `Make(N, s64)`, `Make(3, s64)`, and `Make(M + 1, s64)` all yield the + // same TypeId. `.unresolved` means the return wasn't a type expression + // (e.g. a value-returning function in a type position) → fall through to + // the caller's fallback rather than fabricating a type. + if (findReturnTypeExpr(fd.body)) |ret_node| { + const ty = self.resolveTypeWithBindings(ret_node); + if (ty != .unresolved) return ty; + } + return null; } + /// The type expression a type-returning function yields: the value of its + /// `return` (block body) or the bare expression (arrow body / `=> [K]T`). + /// Used for a non-struct/union return shape, which the struct/union body + /// walkers above don't match. + fn findReturnTypeExpr(body: *const Node) ?*const Node { + if (body.data == .block) { + for (body.data.block.stmts) |stmt| { + if (stmt.data == .return_stmt) return stmt.data.return_stmt.value; + } + return null; + } + return body; + } + /// Instantiate a tagged enum from a type function body. fn instantiateTypeUnion(self: *Lowering, alias_name: []const u8, mangled_name: []const u8, ed: *const ast.EnumDecl) ?TypeId { const table = &self.module.types; diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index ce81ea3..3f063b5 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -116,6 +116,37 @@ pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 { }; } +/// The outcome of folding a comptime-int and narrowing it to a `u32` count +/// (array dimension / Vector lane / value-param count). `foldDimU32` is the +/// SINGLE place a folded integer becomes a `u32`, so the i64→u32 narrowing is +/// range-checked exactly once and no call site does a bare `@intCast` that could +/// panic the compiler on a valid-but-oversized fold (a literal `5_000_000_000` +/// is a valid `i64` yet `> maxInt(u32)` — issue 0087). Each call site maps a +/// non-`.ok` variant onto its own clean diagnostic + `.unresolved` / abort. +pub const DimU32 = union(enum) { + /// Folded to a `u32` in `[min, maxInt(u32)]`. + ok: u32, + /// Not a compile-time integer (runtime value, unbound name, or overflow). + not_const, + /// Folded, but below the required minimum (a negative dim, a non-positive lane). + below_min: i64, + /// Folded, but greater than `maxInt(u32)` — too large for a `u32` count. + too_large: i64, +}; + +/// Fold `node` to a `u32` count through `evalConstIntExpr`, then range-check +/// against `[min, maxInt(u32)]`. THE single fold-to-u32 for every array +/// dimension, Vector lane, and value-param count — routing all of them here +/// guarantees the narrowing is checked once and can never abort the compiler +/// (issue 0087). The fold itself stays in `i64`; only this one conversion is the +/// `u32` gate. +pub fn foldDimU32(node: *const Node, ctx: anytype, min: u32) DimU32 { + const v = evalConstIntExpr(node, ctx) orelse return .not_const; + if (v < @as(i64, min)) return .{ .below_min = v }; + if (v > std.math.maxInt(u32)) return .{ .too_large = v }; + return .{ .ok = @intCast(v) }; +} + pub const GlobalInfo = struct { id: inst.GlobalId, ty: TypeId }; /// Single lowering access point for declaration-name / import / visibility diff --git a/src/ir/semantic_diagnostics.zig b/src/ir/semantic_diagnostics.zig index c52b4cd..f0f5bb3 100644 --- a/src/ir/semantic_diagnostics.zig +++ b/src/ir/semantic_diagnostics.zig @@ -617,12 +617,28 @@ pub const UnknownTypeChecker = struct { /// generic `$N: u32` arg), not a type. Such a position must be skipped by /// the unknown-type walk: a module-const arg (`Vector(N, f32)`) is a value, /// not a type name. `Vector`'s arg 0 is always its lane count; a generic - /// struct template's value-param positions come from its declared params. + /// struct template's value-param positions come from its declared params; a + /// type-RETURNING function (`Make :: ($K: u32, $T: Type) -> Type`) classifies + /// each param from its constraint, mirroring `instantiateTypeFunction` — so + /// `Make(N, s64)` (N a module const) is not walked as the type name "N". fn isValueParamPosition(self: UnknownTypeChecker, base: []const u8, i: usize) bool { if (std.mem.eql(u8, base, "Vector")) return i == 0; if (self.index.struct_template_map.get(base)) |tmpl| { if (i < tmpl.type_params.len) return !tmpl.type_params[i].is_type_param; } + if (self.index.fn_ast_map.get(base)) |fd| { + if (i < fd.type_params.len) { + const tp = fd.type_params[i]; + // A value param is one whose constraint is a non-`Type` type + // expr (`$K: u32`); a `$T: Type` (or any non-type-expr + // constraint) is a type param — identical rule to the binder. + const is_type_param = if (tp.constraint.data == .type_expr) + std.mem.eql(u8, tp.constraint.data.type_expr.name, "Type") + else + true; + return !is_type_param; + } + } return false; } diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index 4ef7398..711d351 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -43,19 +43,23 @@ const StatelessInner = struct { /// Fixed-array dimension at registration time: a literal `[16]T`, a named /// module-global const `N :: 16; [N]T` (typed `N : s64 : 16` too), or a /// constant-foldable expression over those (`[M + 1]`, `[(M + 1) * 2]`). - /// Folds through the shared `program_index.evalConstIntExpr` — the SAME - /// evaluator the stateful body-lowering path uses — so a dimension resolves - /// to one length on every registration-time path (aliases, inline union/enum - /// fields) and matches the direct form (issue 0083). Returns null when the - /// dimension isn't a compile-time integer (a runtime value / non-comptime - /// call, or a name not bound to an integer const). Null propagates to - /// `resolveCompound`, which yields the `.unresolved` sentinel rather than - /// fabricating a 0 length that silently gives a 0-byte array and - /// out-of-bounds element access; the registration caller surfaces the - /// unresolved alias/type as a clean diagnostic. + /// Folds and narrows through the shared `program_index.foldDimU32` (min 0) — + /// the SAME range-checked fold-to-u32 the stateful body-lowering path uses — + /// so a dimension resolves to one length on every registration-time path + /// (aliases, inline union/enum fields) and matches the direct form (issue + /// 0083), and an oversized-but-valid `i64` dim returns null instead of + /// panicking the `@intCast` (issue 0087). Returns null when the dimension + /// isn't a compile-time integer (a runtime value / non-comptime call, or a + /// name not bound to an integer const), is negative, or doesn't fit a `u32`. + /// Null propagates to `resolveCompound`, which yields the `.unresolved` + /// sentinel rather than fabricating a 0 length that silently gives a 0-byte + /// array and out-of-bounds element access; the registration caller surfaces + /// the unresolved alias/type as a clean diagnostic. pub fn resolveArrayLen(self: StatelessInner, len_node: *const Node) ?u32 { - const v = program_index_mod.evalConstIntExpr(len_node, self) orelse return null; - return if (v >= 0) @intCast(v) else null; + return switch (program_index_mod.foldDimU32(len_node, self, 0)) { + .ok => |n| n, + else => null, + }; } /// Leaf-name lookup for the shared dimension evaluator: a name that resolves /// to a module-global integer constant → its value. Shares From e8cc9d03dece910234013737650cc0e1cf80f902 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 12:31:24 +0300 Subject: [PATCH 08/13] fix(ir): precise oversized-dim diagnostic on the alias path (0083) The stateless alias-registration array-dim path collapsed foldDimU32's distinct .too_large / .below_min outcomes into null, so an oversized type alias (Big :: [5000000000]s64) emitted the FALSE 'an array dimension is not a compile-time integer constant' message while the direct form correctly reported 'array dimension 5000000000 does not fit in u32'. Add program_index.reportDimError as the single source of dim-error wording (the stateful path now emits through it too) and type_bridge.foldArrayDim to surface the DimU32 reason at the alias-registration site. An oversized/negative alias dim now routes to reportDimError for the same precise message as the direct form; a genuinely non-const alias dim keeps the alias-specific message. Regression: examples/1131-diagnostics-array-dim-oversized-u32-alias.sx --- ...agnostics-array-dim-oversized-u32-alias.sx | 24 +++++++++ ...nostics-array-dim-oversized-u32-alias.exit | 1 + ...stics-array-dim-oversized-u32-alias.stderr | 5 ++ ...stics-array-dim-oversized-u32-alias.stdout | 1 + ...named-const-array-dimension-miscompiled.md | 21 ++++++++ src/ir/lower.zig | 51 +++++++++++-------- src/ir/program_index.zig | 19 +++++++ src/ir/type_bridge.zig | 13 +++++ 8 files changed, 114 insertions(+), 21 deletions(-) create mode 100644 examples/1131-diagnostics-array-dim-oversized-u32-alias.sx create mode 100644 examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.exit create mode 100644 examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.stderr create mode 100644 examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.stdout diff --git a/examples/1131-diagnostics-array-dim-oversized-u32-alias.sx b/examples/1131-diagnostics-array-dim-oversized-u32-alias.sx new file mode 100644 index 0000000..db32bc0 --- /dev/null +++ b/examples/1131-diagnostics-array-dim-oversized-u32-alias.sx @@ -0,0 +1,24 @@ +// An array dimension that folds to a valid compile-time integer but exceeds a +// `u32` is a hard error — and it must report the SAME precise diagnostic whether +// the array is written directly (`a : [5_000_000_000]s64`, see example 1130) or +// behind a type ALIAS (`Big :: [5_000_000_000]s64`, here). Both forms now route +// the dimension through one shared folder + one shared message map, so they +// cannot diverge. +// +// Regression (issue 0083 / F0.4 attempt 7): the stateless alias-registration +// path collapsed `foldDimU32`'s distinct `.too_large` outcome into `null` and +// emitted ONE generic "an array dimension is not a compile-time integer +// constant" message — FALSE, since 5_000_000_000 IS a compile-time integer +// constant; it merely doesn't fit a `u32`. The alias path now consults the +// shared fold and emits the precise "does not fit in u32" message, matching the +// direct form. (A genuinely non-const alias dim still gets the generic message — +// see example 1129.) +#import "modules/std.sx"; + +Big :: [5000000000]s64; + +main :: () { + a : Big = ---; + a[0] = 7; + print("unreachable: {}\n", a[0]); +} diff --git a/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.exit b/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.stderr b/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.stderr new file mode 100644 index 0000000..7de2858 --- /dev/null +++ b/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.stderr @@ -0,0 +1,5 @@ +error: array dimension 5000000000 does not fit in u32 + --> examples/1131-diagnostics-array-dim-oversized-u32-alias.sx:18:9 + | +18 | Big :: [5000000000]s64; + | ^^^^^^^^^^ diff --git a/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.stdout b/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1131-diagnostics-array-dim-oversized-u32-alias.stdout @@ -0,0 +1 @@ + diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index fe789a4..61a07f7 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -122,6 +122,27 @@ > `program_index.foldDimU32` gate. Files: `src/ir/semantic_diagnostics.zig`, > `src/ir/lower.zig`, `src/ir/program_index.zig`, `src/ir/type_bridge.zig`. > Regression: `examples/0208-generics-value-param-type-function.sx`. +> +> **Diagnostic-accuracy parity (attempt 7).** The fold + layout were correct, but +> the two paths still DIVERGED on the error MESSAGE for an oversized dim. The +> direct form (`a : [5_000_000_000]s64`) reported the accurate "array dimension +> 5000000000 does not fit in u32" (from the stateful `resolveArrayLen`, which +> branches on `foldDimU32`'s `.too_large` / `.below_min` / `.not_const` variants), +> but the type-ALIAS form (`Big :: [5_000_000_000]s64`) reported a FALSE "an array +> dimension is not a compile-time integer constant" — because the stateless +> `resolveArrayLen` collapsed every non-`.ok` `DimU32` to `null`, so the +> alias-registration site had only one generic message to emit. Fix: a single +> wording source `program_index.reportDimError(diag, span, DimU32)` now owns the +> dim-error text; the stateful path emits through it, and the alias-registration +> site re-folds a top-level array dim via the new `type_bridge.foldArrayDim` +> (same shared `foldDimU32`) and routes a `.too_large` / `.below_min` result to +> `reportDimError` — so an oversized alias dim now reports the SAME precise +> message as the direct form. A genuinely non-const alias dim (`[get()]`) still +> gets the alias-specific "not a compile-time integer constant" message (1129). +> Files: `src/ir/program_index.zig`, `src/ir/type_bridge.zig`, `src/ir/lower.zig`. +> Regression: `examples/1131-diagnostics-array-dim-oversized-u32-alias.sx` +> (oversized dim via alias → "does not fit in u32", matching direct example 1130; +> 1129 still proves the non-const path keeps the generic message). ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 1d1995a..0e04c8c 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -712,10 +712,26 @@ pub const Lowering = struct { // dimension is not a compile-time integer constant. Surface // it as a clean diagnostic so the build aborts here rather // than letting `.unresolved` reach codegen and `@panic` in - // sizeOf (issue 0083 — no fabricated 0-length array). + // sizeOf (issue 0083 — no fabricated 0-length array). For a + // top-level array alias, re-fold the dimension so an + // oversized / negative constant emits the SAME precise + // message as the direct form (`a : [N]T`) via the shared + // `program_index.reportDimError` — only a genuinely + // non-const dim gets the generic alias message. if (target_ty == .unresolved) { - if (self.diagnostics) |d| - d.addFmt(.err, cd.value.span, "type alias '{s}' could not be resolved: an array dimension is not a compile-time integer constant", .{cd.name}); + if (self.diagnostics) |d| { + const precise: ?program_index_mod.DimU32 = if (cd.value.data == .array_type_expr) blk: { + const dim = type_bridge.foldArrayDim(cd.value.data.array_type_expr.length, &self.module.types, &self.program_index.type_alias_map, &self.program_index.module_const_map); + break :blk switch (dim) { + .too_large, .below_min => dim, + else => null, + }; + } else null; + if (precise) |dim| + program_index_mod.reportDimError(d, cd.value.data.array_type_expr.length.span, dim) + else + d.addFmt(.err, cd.value.span, "type alias '{s}' could not be resolved: an array dimension is not a compile-time integer constant", .{cd.name}); + } } self.program_index.type_alias_map.put(cd.name, target_ty) catch {}; } else if (cd.value.data == .identifier) { @@ -11651,24 +11667,17 @@ pub const Lowering = struct { /// diagnostic surfaces). The diagnostic — not the returned length — is what /// guarantees no garbage ships (issue 0083). pub fn resolveArrayLen(self: *Lowering, len_node: *const Node) ?u32 { - switch (program_index_mod.foldDimU32(len_node, self, 0)) { - .ok => |n| return n, - .below_min => |v| { - if (self.diagnostics) |d| - d.addFmt(.err, len_node.span, "array dimension must be non-negative, got {}", .{v}); - return 0; - }, - .too_large => |v| { - if (self.diagnostics) |d| - d.addFmt(.err, len_node.span, "array dimension {} does not fit in u32", .{v}); - return 0; - }, - .not_const => { - if (self.diagnostics) |d| - d.addFmt(.err, len_node.span, "array dimension must be a compile-time integer constant", .{}); - return 0; - }, - } + const result = program_index_mod.foldDimU32(len_node, self, 0); + if (result == .ok) return result.ok; + // A non-const / oversized / negative dim is a hard error. Emit the + // shared diagnostic (single wording source — `program_index.reportDimError`, + // also used by the stateless alias path so the two cannot diverge), then + // return a harmless `0` so body lowering finishes without touching the + // `.unresolved` sentinel (which would `@panic` in `sizeOf` mid-lowering, + // before the diagnostic surfaces). The diagnostic — not the returned + // length — guarantees no garbage ships (issue 0083). + if (self.diagnostics) |d| program_index_mod.reportDimError(d, len_node.span, result); + return 0; } /// Leaf-name lookup for the shared dimension evaluator: a name bound to a diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index 3f063b5..91861b4 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -2,6 +2,7 @@ const std = @import("std"); const ast = @import("../ast.zig"); const types = @import("types.zig"); const inst = @import("inst.zig"); +const errors = @import("../errors.zig"); const Node = ast.Node; const TypeId = types.TypeId; @@ -147,6 +148,24 @@ pub fn foldDimU32(node: *const Node, ctx: anytype, min: u32) DimU32 { return .{ .ok = @intCast(v) }; } +/// THE single source of array-dimension diagnostic wording. Both array-dim +/// resolvers — the stateful body-lowering path (`Lowering.resolveArrayLen`) and +/// the stateless registration-time path (the alias-registration site, via +/// `type_bridge.foldArrayDim`) — emit through here, so an oversized / negative / +/// non-const dimension reports the SAME message regardless of whether it was +/// written directly (`a : [N]T`) or via a type alias (`Arr :: [N]T`). Folding +/// the wording into one place is the diagnostic-accuracy half of the issue-0083 +/// unify-or-diverge story: `foldDimU32` is the single fold, this is the single +/// message map. Only call with a non-`.ok` result (the `.ok` arm is a no-op). +pub fn reportDimError(diag: *errors.DiagnosticList, span: ?ast.Span, result: DimU32) void { + switch (result) { + .ok => {}, + .below_min => |v| diag.addFmt(.err, span, "array dimension must be non-negative, got {}", .{v}), + .too_large => |v| diag.addFmt(.err, span, "array dimension {} does not fit in u32", .{v}), + .not_const => diag.addFmt(.err, span, "array dimension must be a compile-time integer constant", .{}), + } +} + pub const GlobalInfo = struct { id: inst.GlobalId, ty: TypeId }; /// Single lowering access point for declaration-name / import / visibility diff --git a/src/ir/type_bridge.zig b/src/ir/type_bridge.zig index 711d351..eef4c67 100644 --- a/src/ir/type_bridge.zig +++ b/src/ir/type_bridge.zig @@ -81,6 +81,19 @@ const StatelessInner = struct { } }; +/// Fold a registration-time array dimension to its `DimU32` outcome through the +/// SAME shared `program_index.foldDimU32` that `StatelessInner.resolveArrayLen` +/// uses — but surface the reason instead of collapsing it to `null`. The +/// alias-registration site calls this so an unresolved `Arr :: [N]T` alias can +/// emit the PRECISE dim diagnostic (oversized `[5_000_000_000]` / negative / +/// non-const) that matches the stateful direct form, rather than one generic +/// "not a compile-time integer constant" message for every failure (issue 0083 — +/// the stateful/stateless diagnostic divergence). +pub fn foldArrayDim(len_node: *const Node, table: *TypeTable, alias_map: AliasMap, consts: ConstMap) program_index_mod.DimU32 { + const si = StatelessInner{ .table = table, .alias_map = alias_map, .consts = consts }; + return program_index_mod.foldDimU32(len_node, si, 0); +} + // ── AST Node → TypeId ─────────────────────────────────────────────────── // Resolve an AST type node into an IR TypeId. Used during lowering when // we only have the parsed AST (no codegen type registry). From e03c087e5a5d2067e15e15fc25c8d9dd35782f41 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 13:16:39 +0300 Subject: [PATCH 09/13] fix(ir): integral-float counts + range-checked value-param binds (0083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 2 (Agra ruling): a compile-time INTEGRAL float (`4.0`, `N : f64 : 4.0`, `N :: 4.0`) used as an array dimension / Vector lane / generic value-param count / `inline for` bound now folds to its integer at the shared leaf — `program_index.floatToIntExact`, used by both the `.float_literal` arm of `evalConstIntExpr` and `moduleConstInt`. All four consumers route through the one evaluator, so `[4.0]s64` lays out the same `[4]s64` uniformly; a non-integral (`4.5`) or negative value stays rejected by the downstream `foldDimU32` gate. Pass-0 now pre-registers float-valued module consts for forward-alias parity with int consts. Item 1: a generic value-param bind (`Box($K: u32)`) never range-checked the folded arg, so `Box(5_000_000_000)` compiled and ran. The bind now range-checks against the param's declared type — a `u32` count through the shared `foldDimU32` gate (making program_index's "single u32 gate for value-param counts" doc true), any other integer type through the new `program_index.intTypeRange` — and emits a clean "value N does not fit in u32 parameter K" otherwise. The declared type is threaded via a new `TemplateParam.value_type`. Regressions: examples 0145 (integral-float array dim), 1504 (Vector lane), 0611 (inline-for bound), 0209 (value-param integral-float), 1132 (non-integral float dim rejected), 1133 (negative float dim rejected), 1134 (oversized u32 value-param rejected) + program_index float-fold unit tests. Gate: zig build, zig build test, 406/0 run_examples. --- .../0145-types-integral-float-array-dim.sx | 29 ++++ ...209-generics-value-param-integral-float.sx | 19 +++ ...0611-comptime-integral-float-inline-for.sx | 14 ++ ...iagnostics-array-dim-non-integral-float.sx | 14 ++ ...33-diagnostics-array-dim-negative-float.sx | 12 ++ ...34-diagnostics-value-param-u32-overflow.sx | 17 +++ examples/1504-vectors-integral-float-lane.sx | 12 ++ .../0145-types-integral-float-array-dim.exit | 1 + ...0145-types-integral-float-array-dim.stderr | 1 + ...0145-types-integral-float-array-dim.stdout | 3 + ...9-generics-value-param-integral-float.exit | 1 + ...generics-value-param-integral-float.stderr | 1 + ...generics-value-param-integral-float.stdout | 1 + ...11-comptime-integral-float-inline-for.exit | 1 + ...-comptime-integral-float-inline-for.stderr | 1 + ...-comptime-integral-float-inline-for.stdout | 1 + ...gnostics-array-dim-non-integral-float.exit | 1 + ...ostics-array-dim-non-integral-float.stderr | 5 + ...ostics-array-dim-non-integral-float.stdout | 1 + ...-diagnostics-array-dim-negative-float.exit | 1 + ...iagnostics-array-dim-negative-float.stderr | 5 + ...iagnostics-array-dim-negative-float.stdout | 1 + ...-diagnostics-value-param-u32-overflow.exit | 1 + ...iagnostics-value-param-u32-overflow.stderr | 5 + ...iagnostics-value-param-u32-overflow.stdout | 1 + .../1504-vectors-integral-float-lane.exit | 1 + .../1504-vectors-integral-float-lane.stderr | 1 + .../1504-vectors-integral-float-lane.stdout | 1 + ...named-const-array-dimension-miscompiled.md | 24 ++++ specs.md | 6 + src/ir/lower.zig | 131 +++++++++++++----- src/ir/program_index.test.zig | 42 ++++++ src/ir/program_index.zig | 70 +++++++++- 33 files changed, 384 insertions(+), 41 deletions(-) create mode 100644 examples/0145-types-integral-float-array-dim.sx create mode 100644 examples/0209-generics-value-param-integral-float.sx create mode 100644 examples/0611-comptime-integral-float-inline-for.sx create mode 100644 examples/1132-diagnostics-array-dim-non-integral-float.sx create mode 100644 examples/1133-diagnostics-array-dim-negative-float.sx create mode 100644 examples/1134-diagnostics-value-param-u32-overflow.sx create mode 100644 examples/1504-vectors-integral-float-lane.sx create mode 100644 examples/expected/0145-types-integral-float-array-dim.exit create mode 100644 examples/expected/0145-types-integral-float-array-dim.stderr create mode 100644 examples/expected/0145-types-integral-float-array-dim.stdout create mode 100644 examples/expected/0209-generics-value-param-integral-float.exit create mode 100644 examples/expected/0209-generics-value-param-integral-float.stderr create mode 100644 examples/expected/0209-generics-value-param-integral-float.stdout create mode 100644 examples/expected/0611-comptime-integral-float-inline-for.exit create mode 100644 examples/expected/0611-comptime-integral-float-inline-for.stderr create mode 100644 examples/expected/0611-comptime-integral-float-inline-for.stdout create mode 100644 examples/expected/1132-diagnostics-array-dim-non-integral-float.exit create mode 100644 examples/expected/1132-diagnostics-array-dim-non-integral-float.stderr create mode 100644 examples/expected/1132-diagnostics-array-dim-non-integral-float.stdout create mode 100644 examples/expected/1133-diagnostics-array-dim-negative-float.exit create mode 100644 examples/expected/1133-diagnostics-array-dim-negative-float.stderr create mode 100644 examples/expected/1133-diagnostics-array-dim-negative-float.stdout create mode 100644 examples/expected/1134-diagnostics-value-param-u32-overflow.exit create mode 100644 examples/expected/1134-diagnostics-value-param-u32-overflow.stderr create mode 100644 examples/expected/1134-diagnostics-value-param-u32-overflow.stdout create mode 100644 examples/expected/1504-vectors-integral-float-lane.exit create mode 100644 examples/expected/1504-vectors-integral-float-lane.stderr create mode 100644 examples/expected/1504-vectors-integral-float-lane.stdout diff --git a/examples/0145-types-integral-float-array-dim.sx b/examples/0145-types-integral-float-array-dim.sx new file mode 100644 index 0000000..cb385c1 --- /dev/null +++ b/examples/0145-types-integral-float-array-dim.sx @@ -0,0 +1,29 @@ +// An array dimension accepts any compile-time numeric constant whose value is a +// positive INTEGRAL number — an integral float (`4.0`) folds to its integer just +// like `4`. A float-typed const (`N : f64 : 4.0`), an untyped-float const +// (`M :: 4.0`), and a direct float literal (`[4.0]s64`) all lay out the same +// `[4]s64` as the integer spelling, so element store/read is in bounds. +// +// Regression (issue 0083 / F0.4 attempt 8, Agra ruling): an integral float used +// as a dimension was wrongly rejected "must be a compile-time integer constant". +// The shared const-int evaluator now folds an integral float literal (and a +// float-typed module const) via `program_index.floatToIntExact`; a non-integral +// float (`4.5`) is still rejected (see 1132). +#import "modules/std.sx"; + +N : f64 : 4.0; // float-typed const +M :: 4.0; // untyped float const + +main :: () { + a : [N]s64 = ---; // dim from a float-typed const + a[0] = 10; a[3] = 40; + print("a len={} a0={} a3={}\n", a.len, a[0], a[3]); + + b : [M]s64 = ---; // dim from an untyped float const + b[1] = 21; + print("b len={} b1={}\n", b.len, b[1]); + + c : [4.0]s64 = ---; // direct integral-float-literal dim + c[2] = 32; + print("c len={} c2={}\n", c.len, c[2]); +} diff --git a/examples/0209-generics-value-param-integral-float.sx b/examples/0209-generics-value-param-integral-float.sx new file mode 100644 index 0000000..01910e4 --- /dev/null +++ b/examples/0209-generics-value-param-integral-float.sx @@ -0,0 +1,19 @@ +// A generic value parameter (`$K: u32`) binds a literal (`Vec(3, s64)`) and an +// integral-float named const (`Vec(L, s64)` with `L : f64 : 4.0`) to the same +// integer a plain `4` would — the value-param arg folds through the shared +// const-int evaluator, so the integral-float rule (F0.4 attempt 8, Agra ruling) +// reaches value params too. The folded value is the array length `[K]s64`. +// +// The bind is range-checked against the declared `u32` (an out-of-range arg is a +// clean compile error — see 1134); a valid in-range value binds normally. +#import "modules/std.sx"; + +Vec :: struct ($K: u32, $T: Type) { data: [K]T; } + +L : f64 : 4.0; + +main :: () { + a : Vec(3, s64) = ---; // literal value param + b : Vec(L, s64) = ---; // integral-float named-const value param → 4 + print("a.len={} b.len={}\n", a.data.len, b.data.len); // 3 and 4 +} diff --git a/examples/0611-comptime-integral-float-inline-for.sx b/examples/0611-comptime-integral-float-inline-for.sx new file mode 100644 index 0000000..d3cf3a9 --- /dev/null +++ b/examples/0611-comptime-integral-float-inline-for.sx @@ -0,0 +1,14 @@ +// An `inline for 0..M` bound accepts an integral float constant — `M :: 3.0` +// unrolls the same three iterations as `M :: 3`. The inline-for bound folder +// (`evalComptimeInt`) delegates to the shared const-int evaluator, so the +// integral-float rule (issue 0083 / F0.4 attempt 8, Agra ruling) applies here +// too. +#import "modules/std.sx"; + +M :: 3.0; + +main :: () { + s := 0; + inline for 0..M: (i) { s += i; } + print("sum 0..M = {}\n", s); // 0 + 1 + 2 = 3 +} diff --git a/examples/1132-diagnostics-array-dim-non-integral-float.sx b/examples/1132-diagnostics-array-dim-non-integral-float.sx new file mode 100644 index 0000000..2a74b76 --- /dev/null +++ b/examples/1132-diagnostics-array-dim-non-integral-float.sx @@ -0,0 +1,14 @@ +// A NON-integral float constant (`4.5`) used as an array dimension is a hard +// error — only an integral float (`4.0`) folds to a count. Clean diagnostic + +// non-zero exit, NOT a fabricated length. +// +// Regression (F0.4 attempt 8, Agra ruling): the integral-float rule accepts +// `4.0` as a dimension but must keep rejecting `4.5` (it is not an integer). +#import "modules/std.sx"; + +N : f64 : 4.5; + +main :: () { + a : [N]s64 = ---; + print("unreachable: {}\n", a.len); +} diff --git a/examples/1133-diagnostics-array-dim-negative-float.sx b/examples/1133-diagnostics-array-dim-negative-float.sx new file mode 100644 index 0000000..d7a94b7 --- /dev/null +++ b/examples/1133-diagnostics-array-dim-negative-float.sx @@ -0,0 +1,12 @@ +// A NEGATIVE integral float (`-2.0`) used as an array dimension is a hard error. +// The integral-float rule folds and negates it to `-2`, then the shared u32 dim +// gate rejects a below-minimum dimension — a clean diagnostic + non-zero exit. +// +// Regression (F0.4 attempt 8, Agra ruling): integral floats fold, but a negative +// result is still rejected (a dimension must be non-negative). +#import "modules/std.sx"; + +main :: () { + a : [-2.0]s64 = ---; + print("unreachable: {}\n", a.len); +} diff --git a/examples/1134-diagnostics-value-param-u32-overflow.sx b/examples/1134-diagnostics-value-param-u32-overflow.sx new file mode 100644 index 0000000..1d5e950 --- /dev/null +++ b/examples/1134-diagnostics-value-param-u32-overflow.sx @@ -0,0 +1,17 @@ +// A generic value-param arg that does not fit the param's declared integer type +// (`Box(5_000_000_000)` for `$K: u32`) is a hard error — a clean diagnostic + +// non-zero exit, NOT a silent truncating bind. +// +// Regression (F0.4 attempt 8, item 1): `resolveValueParamArg` bound the folded +// i64 without range-checking the declared type, so an out-of-u32 arg compiled +// and ran. The bind now routes a `u32` count through the shared +// `program_index.foldDimU32` gate (the same one array dims / Vector lanes use), +// so an oversized value is rejected before instantiation. +#import "modules/std.sx"; + +Box :: struct ($K: u32) { value: s64; } + +main :: () { + b : Box(5000000000) = ---; + print("unreachable\n"); +} diff --git a/examples/1504-vectors-integral-float-lane.sx b/examples/1504-vectors-integral-float-lane.sx new file mode 100644 index 0000000..c3b11cf --- /dev/null +++ b/examples/1504-vectors-integral-float-lane.sx @@ -0,0 +1,12 @@ +// A Vector lane count accepts an integral float constant — `L : f64 : 4.0` lays +// out the same `Vector(4, f32)` as the literal `4`. The lane resolver shares the +// const-int evaluator with the array-dim path, so the integral-float rule +// (issue 0083 / F0.4 attempt 8, Agra ruling) applies uniformly. +#import "modules/std.sx"; + +L : f64 : 4.0; + +main :: () { + v : Vector(L, f32) = .[1.0, 2.0, 3.0, 4.0]; + print("v0={} v2={} v3={}\n", v[0], v[2], v[3]); +} diff --git a/examples/expected/0145-types-integral-float-array-dim.exit b/examples/expected/0145-types-integral-float-array-dim.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0145-types-integral-float-array-dim.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0145-types-integral-float-array-dim.stderr b/examples/expected/0145-types-integral-float-array-dim.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0145-types-integral-float-array-dim.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0145-types-integral-float-array-dim.stdout b/examples/expected/0145-types-integral-float-array-dim.stdout new file mode 100644 index 0000000..5e7a4fd --- /dev/null +++ b/examples/expected/0145-types-integral-float-array-dim.stdout @@ -0,0 +1,3 @@ +a len=4 a0=10 a3=40 +b len=4 b1=21 +c len=4 c2=32 diff --git a/examples/expected/0209-generics-value-param-integral-float.exit b/examples/expected/0209-generics-value-param-integral-float.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0209-generics-value-param-integral-float.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0209-generics-value-param-integral-float.stderr b/examples/expected/0209-generics-value-param-integral-float.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0209-generics-value-param-integral-float.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0209-generics-value-param-integral-float.stdout b/examples/expected/0209-generics-value-param-integral-float.stdout new file mode 100644 index 0000000..35313c4 --- /dev/null +++ b/examples/expected/0209-generics-value-param-integral-float.stdout @@ -0,0 +1 @@ +a.len=3 b.len=4 diff --git a/examples/expected/0611-comptime-integral-float-inline-for.exit b/examples/expected/0611-comptime-integral-float-inline-for.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0611-comptime-integral-float-inline-for.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0611-comptime-integral-float-inline-for.stderr b/examples/expected/0611-comptime-integral-float-inline-for.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0611-comptime-integral-float-inline-for.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0611-comptime-integral-float-inline-for.stdout b/examples/expected/0611-comptime-integral-float-inline-for.stdout new file mode 100644 index 0000000..20ad783 --- /dev/null +++ b/examples/expected/0611-comptime-integral-float-inline-for.stdout @@ -0,0 +1 @@ +sum 0..M = 3 diff --git a/examples/expected/1132-diagnostics-array-dim-non-integral-float.exit b/examples/expected/1132-diagnostics-array-dim-non-integral-float.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1132-diagnostics-array-dim-non-integral-float.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1132-diagnostics-array-dim-non-integral-float.stderr b/examples/expected/1132-diagnostics-array-dim-non-integral-float.stderr new file mode 100644 index 0000000..30a6c22 --- /dev/null +++ b/examples/expected/1132-diagnostics-array-dim-non-integral-float.stderr @@ -0,0 +1,5 @@ +error: array dimension must be a compile-time integer constant + --> examples/1132-diagnostics-array-dim-non-integral-float.sx:12:10 + | +12 | a : [N]s64 = ---; + | ^ diff --git a/examples/expected/1132-diagnostics-array-dim-non-integral-float.stdout b/examples/expected/1132-diagnostics-array-dim-non-integral-float.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1132-diagnostics-array-dim-non-integral-float.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1133-diagnostics-array-dim-negative-float.exit b/examples/expected/1133-diagnostics-array-dim-negative-float.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1133-diagnostics-array-dim-negative-float.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1133-diagnostics-array-dim-negative-float.stderr b/examples/expected/1133-diagnostics-array-dim-negative-float.stderr new file mode 100644 index 0000000..d9a5429 --- /dev/null +++ b/examples/expected/1133-diagnostics-array-dim-negative-float.stderr @@ -0,0 +1,5 @@ +error: array dimension must be non-negative, got -2 + --> examples/1133-diagnostics-array-dim-negative-float.sx:10:10 + | +10 | a : [-2.0]s64 = ---; + | ^^^^ diff --git a/examples/expected/1133-diagnostics-array-dim-negative-float.stdout b/examples/expected/1133-diagnostics-array-dim-negative-float.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1133-diagnostics-array-dim-negative-float.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1134-diagnostics-value-param-u32-overflow.exit b/examples/expected/1134-diagnostics-value-param-u32-overflow.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1134-diagnostics-value-param-u32-overflow.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1134-diagnostics-value-param-u32-overflow.stderr b/examples/expected/1134-diagnostics-value-param-u32-overflow.stderr new file mode 100644 index 0000000..aea3629 --- /dev/null +++ b/examples/expected/1134-diagnostics-value-param-u32-overflow.stderr @@ -0,0 +1,5 @@ +error: value 5000000000 does not fit in u32 parameter K + --> examples/1134-diagnostics-value-param-u32-overflow.sx:15:13 + | +15 | b : Box(5000000000) = ---; + | ^^^^^^^^^^ diff --git a/examples/expected/1134-diagnostics-value-param-u32-overflow.stdout b/examples/expected/1134-diagnostics-value-param-u32-overflow.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1134-diagnostics-value-param-u32-overflow.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1504-vectors-integral-float-lane.exit b/examples/expected/1504-vectors-integral-float-lane.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/1504-vectors-integral-float-lane.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/1504-vectors-integral-float-lane.stderr b/examples/expected/1504-vectors-integral-float-lane.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1504-vectors-integral-float-lane.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/1504-vectors-integral-float-lane.stdout b/examples/expected/1504-vectors-integral-float-lane.stdout new file mode 100644 index 0000000..142e505 --- /dev/null +++ b/examples/expected/1504-vectors-integral-float-lane.stdout @@ -0,0 +1 @@ +v0=1.000000 v2=3.000000 v3=4.000000 diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index 61a07f7..b6b9df5 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -143,6 +143,30 @@ > Regression: `examples/1131-diagnostics-array-dim-oversized-u32-alias.sx` > (oversized dim via alias → "does not fit in u32", matching direct example 1130; > 1129 still proves the non-const path keeps the generic message). +> +> **Integral-float counts + value-param range gate (attempt 8, Agra ruling).** +> Two finishing items on the shared count path. (1) An *integral* compile-time +> FLOAT used as a count (array dim, Vector lane, value-param, `inline for` bound) +> was wrongly rejected — `N : f64 : 4.0`, `N :: 4.0`, and `[4.0]s64` all said +> "must be a compile-time integer constant". The shared evaluator now folds an +> integral float to its integer at the single leaf +> (`program_index.floatToIntExact`, used by both the `.float_literal` arm of +> `evalConstIntExpr` and `moduleConstInt`), so every consumer accepts `4.0` ≡ `4` +> while a non-integral (`4.5`) or negative value is still rejected by the +> downstream `foldDimU32` gate. (2) A generic value-param bind (`Box($K: u32)`) +> never range-checked the folded arg against its declared type, so +> `Box(5_000_000_000)` compiled and ran; the bind now routes a `u32` count +> through the same `foldDimU32` gate (and any other declared integer type through +> `program_index.intTypeRange`), so an out-of-range arg is a clean compile error +> ("value 5000000000 does not fit in u32 parameter K"). Files: +> `src/ir/program_index.zig` (+`.test.zig`), `src/ir/lower.zig`, `specs.md`. +> Regressions: `examples/0145-types-integral-float-array-dim.sx`, +> `examples/1504-vectors-integral-float-lane.sx`, +> `examples/0611-comptime-integral-float-inline-for.sx`, +> `examples/0209-generics-value-param-integral-float.sx`, +> `examples/1132-diagnostics-array-dim-non-integral-float.sx`, +> `examples/1133-diagnostics-array-dim-negative-float.sx`, +> `examples/1134-diagnostics-value-param-u32-overflow.sx`. ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/specs.md b/specs.md index bf7a57e..4958ae5 100644 --- a/specs.md +++ b/specs.md @@ -651,6 +651,12 @@ Arrays can also be constructed programmatically with the `Array` builtin: MyArr :: Array(5, s32); // equivalent to [5]s32 ``` +An array dimension — and likewise a `Vector` lane count, a generic value-param +count, and an `inline for` bound — accepts any compile-time numeric constant +whose value is a positive integral number. An integral float (`4.0`, or a +float-typed const `N : f64 : 4.0`) folds to its integer (`[4.0]s64` ≡ `[4]s64`); +a non-integral float (`4.5`) or a negative value is rejected. + ### Slice Types A slice `[]T` is a fat pointer `{ptr, i64}` referencing a contiguous sequence of `T` elements. Same runtime layout as `string`. ```sx diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 0e04c8c..d781131 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -653,22 +653,27 @@ pub const Lowering = struct { /// Pass 1: Scan declarations — register ASTs and extern stubs, but don't lower bodies. fn scanDecls(self: *Lowering, decls: []const *const Node) void { - // Pass 0: register every integer-valued module const (`N :: 16` and the - // typed `N : s64 : 16`) BEFORE any type alias is resolved below. A type - // alias whose dimension is a named const (`Arr :: [N]T`) resolves its - // dimension eagerly here, on the stateless registration path; that path - // can only read `module_const_map`. Untyped consts would otherwise be - // registered only in declaration order (pass 1) and typed ones only after - // the alias fixpoint (pass 2) — so an alias declared before its const, or - // any alias over a typed const, saw an empty table and miscompiled the - // dimension to length 0 (issue 0083). The dimension only needs the value, - // so a placeholder type is fine; pass 2 overwrites typed consts with the - // resolved annotation type (issue 0070). + // Pass 0: register every numeric-literal module const (`N :: 16` and the + // typed `N : s64 : 16`, plus float-valued `N :: 4.0` / `N : f64 : 4.0`) + // BEFORE any type alias is resolved below. A type alias whose dimension is + // a named const (`Arr :: [N]T`) resolves its dimension eagerly here, on + // the stateless registration path; that path can only read + // `module_const_map`. Untyped consts would otherwise be registered only in + // declaration order (pass 1) and typed ones only after the alias fixpoint + // (pass 2) — so an alias declared before its const, or any alias over a + // typed const, saw an empty table and miscompiled the dimension to length + // 0 (issue 0083). A float-valued const resolves to a dimension only when + // its value is integral (`floatToIntExact`); pre-registering it keeps the + // forward-alias float path identical to the int path. The dimension only + // needs the value, so a placeholder type is fine; pass 2 overwrites typed + // consts with the resolved annotation type (issue 0070). for (decls) |decl| { if (decl.data != .const_decl) continue; const cd = decl.data.const_decl; - if (cd.value.data == .int_literal) { - self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .s64 }) catch {}; + switch (cd.value.data) { + .int_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .s64 }) catch {}, + .float_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .f64 }) catch {}, + else => {}, } } for (decls) |decl| { @@ -11852,17 +11857,65 @@ pub const Lowering = struct { } } - /// Resolve a generic value-param argument (`$N: u32`) to its compile-time - /// integer through the shared `evalConstIntExpr` folder, so a module/generic - /// const arg (`Vec(N, f32)`) binds the same value — and mangles to the same - /// instantiation — a literal (`Vec(3, f32)`) would. A non-const arg emits a - /// clean diagnostic and returns null; the caller bails rather than - /// fabricating a 0 binding under a wrong mangled name. - fn resolveValueParamArg(self: *Lowering, arg_node: *const Node) ?i64 { - if (program_index_mod.evalConstIntExpr(arg_node, self)) |v| return v; + /// Resolve a generic value-param argument (`$K: u32`) to its compile-time + /// integer AND verify it fits the param's declared integer type. The folded + /// value is bound and mangled into the instantiation name, so a module/generic + /// const arg (`Vec(N, f32)`), a const expression (`Make(M + 1, s64)`), an + /// integral float (`Box(4.0)` → 4), and a literal (`Vec(3, f32)`) all bind the + /// same value a literal would. An out-of-range arg (`Box(5_000_000_000)` for a + /// `u32` param) or a non-const arg emits a clean diagnostic and returns null; + /// the caller bails rather than binding a truncated / fabricated value under a + /// wrong mangled name. + /// + /// `type_name` is the param's declared constraint type (`"u32"`, null if + /// unknown). A `u32` count routes through the shared + /// `program_index.foldDimU32` — the SAME fold-and-narrow gate an array dim / + /// Vector lane uses — so the documented "single u32 gate for value-param + /// counts" holds; any other integer type range-checks against + /// `program_index.intTypeRange`; an unrecognised type folds without bounding. + fn resolveValueParamArg(self: *Lowering, arg_node: *const Node, param_name: []const u8, type_name: ?[]const u8) ?i64 { + if (type_name) |tn| { + if (std.mem.eql(u8, tn, "u32")) { + switch (program_index_mod.foldDimU32(arg_node, self, 0)) { + .ok => |n| return n, + .not_const => { + self.diagValueParamNotConst(arg_node, param_name); + return null; + }, + .below_min => |v| { + self.diagValueParamRange(arg_node, param_name, tn, v); + return null; + }, + .too_large => |v| { + self.diagValueParamRange(arg_node, param_name, tn, v); + return null; + }, + } + } + } + const v = program_index_mod.evalConstIntExpr(arg_node, self) orelse { + self.diagValueParamNotConst(arg_node, param_name); + return null; + }; + if (type_name) |tn| { + if (program_index_mod.intTypeRange(tn)) |r| { + if (v < r.min or v > r.max) { + self.diagValueParamRange(arg_node, param_name, tn, v); + return null; + } + } + } + return v; + } + + fn diagValueParamNotConst(self: *Lowering, arg_node: *const Node, param_name: []const u8) void { if (self.diagnostics) |d| - d.addFmt(.err, arg_node.span, "generic value parameter must be a compile-time integer constant", .{}); - return null; + d.addFmt(.err, arg_node.span, "generic value parameter '{s}' must be a compile-time integer constant", .{param_name}); + } + + fn diagValueParamRange(self: *Lowering, arg_node: *const Node, param_name: []const u8, type_name: []const u8, value: i64) void { + if (self.diagnostics) |d| + d.addFmt(.err, arg_node.span, "value {} does not fit in {s} parameter {s}", .{ value, type_name, param_name }); } /// Resolve a .call node that represents a type constructor (e.g., List(T), Vector(N, T)). @@ -11999,8 +12052,9 @@ pub const Lowering = struct { const tname = self.formatTypeName(ty); name_parts.appendSlice(self.alloc, tname) catch {}; } else { - // Value param (e.g., $N: u32) — fold to a compile-time integer. - const val = self.resolveValueParamArg(args[i]) orelse return .unresolved; + // Value param (e.g., $N: u32) — fold to a compile-time integer + // and range-check against its declared type. + const val = self.resolveValueParamArg(args[i], tp.name, tp.value_type) orelse return .unresolved; cvb.put(tp.name, val) catch {}; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{d}", .{val}) catch "0"; @@ -12093,8 +12147,10 @@ pub const Lowering = struct { const tname = self.formatTypeName(ty); name_parts.appendSlice(self.alloc, tname) catch {}; } else { - // Value param (e.g., $N: u32) — fold to a compile-time integer. - const val = self.resolveValueParamArg(args[i]) orelse return null; + // Value param (e.g., $N: u32) — fold to a compile-time integer + // and range-check against its declared type. + const vp_type: ?[]const u8 = if (tp.constraint.data == .type_expr) tp.constraint.data.type_expr.name else null; + const val = self.resolveValueParamArg(args[i], tp.name, vp_type) orelse return null; cvb.put(tp.name, val) catch {}; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{d}", .{val}) catch "0"; @@ -12309,19 +12365,26 @@ pub const Lowering = struct { // Build owned type_params const tps = self.alloc.alloc(TemplateParam, sd.type_params.len) catch return; for (sd.type_params, 0..) |tp, i| { + const is_type_param = tp.is_variadic or (if (tp.constraint.data == .type_expr) blk: { + const cname = tp.constraint.data.type_expr.name; + // "Type" or a protocol name → type param + break :blk std.mem.eql(u8, cname, "Type") or + self.program_index.protocol_decl_map.contains(cname) or + self.program_index.protocol_ast_map.contains(cname); + } else false); tps[i] = .{ .name = self.alloc.dupe(u8, tp.name) catch return, // $T: Type, $T: Lerpable, $T: Type/Eq — all are type params. // `..$Ts: []Type` (variadic) is a type-pack param. Only value // params like $N: u32 are non-type. - .is_type_param = tp.is_variadic or (if (tp.constraint.data == .type_expr) blk: { - const cname = tp.constraint.data.type_expr.name; - // "Type" or a protocol name → type param - break :blk std.mem.eql(u8, cname, "Type") or - self.program_index.protocol_decl_map.contains(cname) or - self.program_index.protocol_ast_map.contains(cname); - } else false), + .is_type_param = is_type_param, .is_variadic = tp.is_variadic, + // Capture a value param's declared type name (`$K: u32` → + // "u32") so instantiation can range-check the folded arg. + .value_type = if (!is_type_param and tp.constraint.data == .type_expr) + (self.alloc.dupe(u8, tp.constraint.data.type_expr.name) catch null) + else + null, }; } diff --git a/src/ir/program_index.test.zig b/src/ir/program_index.test.zig index 84a4cb9..b087b69 100644 --- a/src/ir/program_index.test.zig +++ b/src/ir/program_index.test.zig @@ -116,6 +116,9 @@ const DimCtx = struct { fn nLit(v: i64) ast.Node { return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .int_literal = .{ .value = v } } }; } +fn nFloat(v: f64) ast.Node { + return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .float_literal = .{ .value = v } } }; +} fn nIdent(name: []const u8) ast.Node { return .{ .span = .{ .start = 0, .end = 0 }, .data = .{ .identifier = .{ .name = name } } }; } @@ -191,3 +194,42 @@ test "evalConstIntExpr folds constant-expression array dimensions, halts on non- try std.testing.expect(eval(&cmp, ctx) == null); try std.testing.expect(eval(&ovf, ctx) == null); } + +test "floatToIntExact accepts integral floats, rejects the rest" { + const f = pi.floatToIntExact; + // Integral floats (positive, zero, negative) fold to their exact integer. + try std.testing.expectEqual(@as(?i64, 4), f(4.0)); + try std.testing.expectEqual(@as(?i64, 0), f(0.0)); + try std.testing.expectEqual(@as(?i64, -2), f(-2.0)); + // Non-integral / non-finite → null (the caller's clean halt). + try std.testing.expect(f(4.5) == null); + try std.testing.expect(f(0.1) == null); + try std.testing.expect(f(std.math.inf(f64)) == null); + try std.testing.expect(f(-std.math.inf(f64)) == null); + try std.testing.expect(f(std.math.nan(f64)) == null); + // Out-of-i64-range integral floats → null (no @intFromFloat range panic). + // `-2^63` is exactly the i64 minimum and IS representable. + try std.testing.expectEqual(@as(?i64, std.math.minInt(i64)), f(-9223372036854775808.0)); + try std.testing.expect(f(9223372036854775808.0) == null); // 2^63, just past maxInt(i64) + try std.testing.expect(f(1.0e30) == null); +} + +test "evalConstIntExpr folds an integral float literal, halts on a fractional one" { + const eval = pi.evalConstIntExpr; + const ctx = DimCtx{}; + + var f4 = nFloat(4.0); + var f45 = nFloat(4.5); + var one = nLit(1); + + // A direct integral float dimension (`[4.0]T`) folds; `4.5` does not. + try std.testing.expectEqual(@as(?i64, 4), eval(&f4, ctx)); + try std.testing.expect(eval(&f45, ctx) == null); + + // It composes inside an expression dimension (`4.0 + 1` → 5); a fractional + // operand poisons the whole fold to null. + var add = nBin(.add, &f4, &one); + var addbad = nBin(.add, &f45, &one); + try std.testing.expectEqual(@as(?i64, 5), eval(&add, ctx)); + try std.testing.expect(eval(&addbad, ctx) == null); +} diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index 91861b4..aebb2dd 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -18,6 +18,10 @@ pub const TemplateParam = struct { name: []const u8, is_type_param: bool, // true for $T: Type, false for $N: u32 is_variadic: bool = false, // `..$Ts: []Type` — binds remaining type args as a pack + // Declared constraint type NAME for a value (non-type) param (`$K: u32` → + // "u32"), used to range-check the folded arg at instantiation; null for a + // type/variadic param or when the constraint isn't a plain type name. + value_type: ?[]const u8 = null, }; pub const ProtocolMethodInfo = struct { @@ -41,6 +45,24 @@ pub const ModuleConstInfo = struct { ty: TypeId, }; +/// A finite, INTEGRAL `f64` (`4.0`) → its exact `i64` value; a non-integral +/// (`4.5`), infinite, NaN, or out-of-`i64`-range float → null. THE single place +/// the "an integral float counts as an integer count" rule lives, shared by the +/// `.float_literal` leaf of `evalConstIntExpr` (a direct `[4.0]T` dim) and +/// `moduleConstInt` (a float-typed module const `N : f64 : 4.0` used as a +/// count). One source, so an integral float resolves to the SAME integer at +/// every dimension / lane / count / value-param / inline-for site; positivity +/// and u32-range are still enforced downstream by `foldDimU32`. +pub fn floatToIntExact(v: f64) ?i64 { + if (!std.math.isFinite(v)) return null; + if (@trunc(v) != v) return null; + // `-2^63` is exactly representable and is `minInt(i64)`; `2^63` is the first + // f64 above `maxInt(i64)`. Guard both so `@intFromFloat`'s range assert can + // never trip on a valid-but-oversized integral float. + if (v < -9223372036854775808.0 or v >= 9223372036854775808.0) return null; + return @intFromFloat(v); +} + /// A name bound to a module-global integer constant → its value, else null. /// SINGLE source for both array-dimension resolvers — the stateful /// body-lowering path (`Lowering.comptimeIntNamed`) and the stateless @@ -48,12 +70,17 @@ pub const ModuleConstInfo = struct { /// which named consts a `[N]T` dimension resolves to; if they diverge, an array /// laid out via a type alias (`Arr :: [N]T`, stateless) gets a different length /// than the direct form (`a : [N]T`, stateful) — the issue-0083 miscompile. -/// Untyped (`N :: 16`) and typed (`N : s64 : 16`) consts both store an -/// `.int_literal` value node, so both resolve here identically. +/// Untyped (`N :: 16`) and typed (`N : s64 : 16`) consts store an `.int_literal` +/// value node; a float-typed const (`N : f64 : 4.0`, `N :: 4.0`) stores a +/// `.float_literal` and resolves iff its value is an integral float (via +/// `floatToIntExact`) — `4.5` is not an integer → null. pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), name: []const u8) ?i64 { const ci = consts.get(name) orelse return null; - if (ci.value.data == .int_literal) return ci.value.data.int_literal.value; - return null; + return switch (ci.value.data) { + .int_literal => |lit| lit.value, + .float_literal => |lit| floatToIntExact(lit.value), + else => null, + }; } /// Evaluate a constant integer expression to its value. THE single @@ -62,9 +89,10 @@ pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), name: [ /// args (`Vec(N, f32)`), and `inline for 0..M` bounds all route here so they /// cannot disagree on what a given expression evaluates to (the issue-0083 /// two-resolver class of bug). Folds integer `+ - * / %` and unary negate over -/// int literals and named module / comptime consts — recursively, so nested and -/// parenthesised forms (`[M + N - 1]`, `[(M + 1) * 2]`) fold (a grouping `(…)` -/// carries no AST node; the parser returns the inner expression). +/// int literals, integral float literals (`[4.0]T` → 4, via `floatToIntExact`), +/// and named module / comptime consts — recursively, so nested and parenthesised +/// forms (`[M + N - 1]`, `[(M + 1) * 2]`) fold (a grouping `(…)` carries no AST +/// node; the parser returns the inner expression). /// /// Leaves resolve through the ctx, so each call site shares the SAME folding /// logic while contributing its own bindings: @@ -83,6 +111,8 @@ pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), name: [ pub fn evalConstIntExpr(node: *const Node, ctx: anytype) ?i64 { return switch (node.data) { .int_literal => |lit| lit.value, + // An integral float literal (`[4.0]T`) folds to its integer; `4.5` → null. + .float_literal => |lit| floatToIntExact(lit.value), .identifier => |id| ctx.lookupDimName(id.name), .type_expr => |te| ctx.lookupDimName(te.name), .field_access => |fa| blk: { @@ -166,6 +196,32 @@ pub fn reportDimError(diag: *errors.DiagnosticList, span: ?ast.Span, result: Dim } } +/// The inclusive `[min, max]` integer range a value of a fixed-width integer +/// type can hold, addressed by the type NAME as written on a generic value-param +/// constraint (`$K: u32`). null for a non-integer / unrecognised name — the +/// caller then skips the range check (folds without bounding) rather than +/// guessing. Bounds are clamped into `i64`: a `u64`/`usize` ceiling exceeds +/// `i64`, but a folded value-param arg is already an `i64`, so `maxInt(i64)` is +/// its effective ceiling and the only failure a `u64` param can have is a +/// negative arg. THE single declared-type → range map for the value-param gate, +/// so the bound at every binding site agrees. The `u32` count case is gated +/// through `foldDimU32` instead (the documented dim/lane/value-param u32 gate); +/// both encode the same `[0, maxInt(u32)]`. +pub const IntRange = struct { min: i64, max: i64 }; +pub fn intTypeRange(name: []const u8) ?IntRange { + const eql = std.mem.eql; + if (eql(u8, name, "u8")) return .{ .min = 0, .max = std.math.maxInt(u8) }; + if (eql(u8, name, "u16")) return .{ .min = 0, .max = std.math.maxInt(u16) }; + if (eql(u8, name, "u32")) return .{ .min = 0, .max = std.math.maxInt(u32) }; + if (eql(u8, name, "u64") or eql(u8, name, "usize")) return .{ .min = 0, .max = std.math.maxInt(i64) }; + if (eql(u8, name, "s8")) return .{ .min = std.math.minInt(i8), .max = std.math.maxInt(i8) }; + if (eql(u8, name, "s16")) return .{ .min = std.math.minInt(i16), .max = std.math.maxInt(i16) }; + if (eql(u8, name, "s32")) return .{ .min = std.math.minInt(i32), .max = std.math.maxInt(i32) }; + if (eql(u8, name, "s64") or eql(u8, name, "isize") or eql(u8, name, "int")) + return .{ .min = std.math.minInt(i64), .max = std.math.maxInt(i64) }; + return null; +} + pub const GlobalInfo = struct { id: inst.GlobalId, ty: TypeId }; /// Single lowering access point for declaration-name / import / visibility From a821323c3c56a5d131708bc04ede3ab416f5a5d9 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 14:09:46 +0300 Subject: [PATCH 10/13] fix(ir): converge the comptime-int count surface (0083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three adjacent cells of the shared count surface still diverged from the rest; all now route through the same leaf+fold+narrow+diagnose path. 1. Aliased integer constraint bypassed the value-param range gate — only builtin constraint names matched intTypeRange, so Box(5_000_000_000) with `$K: Count` (Count :: u32) compiled and bound a truncated value. resolveValueParamArg (shared by both the struct AND type-fn binder) now resolves the constraint to its underlying builtin via canonicalIntConstraintName (Count -> u32, Small -> s8) before range-checking, so an aliased integer constraint behaves exactly like the builtin it names. 2. A named const with an expression RHS (M :: 2; N :: M + 1) did not fold as a count — moduleConstInt read only a literal RHS node. It now folds every const's RHS through the shared evalConstIntExpr, cycle-guarded (mutual / self cycles fold to null, not a stack overflow), and pass-0 pre-registers expression-RHS consts. N :: M + 1 == 3 at every consumer: dim (direct + alias), Vector lane, value-param (struct + type-fn), inline for. 3. Stateful resolveArrayLen still fabricated length 0 after a failed fold; it now returns null -> the .unresolved sentinel (no fabrication). The binding's lowering never reaches sizeOf (alloca defers it; hasErrors aborts first) and a field access on an already-diagnosed .unresolved value is poison-suppressed (emitFieldError), so a failed-fold dim emits ONE clean diagnostic with no panic. Regressions: examples/0146 (full positive matrix — every consumer x leaf form), 1135 (aliased u32 + s8 overflow), 1136 (direct non-const dim halts cleanly). The cascade cleanup also tightened 1502/1503 to one diagnostic. Unit test added for moduleConstInt expression-folding + cycle detection. --- examples/0146-types-comptime-count-matrix.sx | 68 +++++++++++++++++++ ...s-value-param-alias-constraint-overflow.sx | 23 +++++++ ...tics-array-dim-nonconst-direct-no-crash.sx | 22 ++++++ .../0146-types-comptime-count-matrix.exit | 1 + .../0146-types-comptime-count-matrix.stderr | 1 + .../0146-types-comptime-count-matrix.stdout | 13 ++++ ...value-param-alias-constraint-overflow.exit | 1 + ...lue-param-alias-constraint-overflow.stderr | 11 +++ ...lue-param-alias-constraint-overflow.stdout | 1 + ...cs-array-dim-nonconst-direct-no-crash.exit | 1 + ...-array-dim-nonconst-direct-no-crash.stderr | 5 ++ ...-array-dim-nonconst-direct-no-crash.stdout | 1 + ...1502-vectors-runtime-lane-not-const.stderr | 6 -- ...1503-vectors-oversized-lane-not-u32.stderr | 6 -- ...named-const-array-dimension-miscompiled.md | 28 ++++++++ src/ir/lower.zig | 66 +++++++++++++++--- src/ir/program_index.test.zig | 47 +++++++++++++ src/ir/program_index.zig | 60 +++++++++++++--- 18 files changed, 328 insertions(+), 33 deletions(-) create mode 100644 examples/0146-types-comptime-count-matrix.sx create mode 100644 examples/1135-diagnostics-value-param-alias-constraint-overflow.sx create mode 100644 examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx create mode 100644 examples/expected/0146-types-comptime-count-matrix.exit create mode 100644 examples/expected/0146-types-comptime-count-matrix.stderr create mode 100644 examples/expected/0146-types-comptime-count-matrix.stdout create mode 100644 examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.exit create mode 100644 examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.stderr create mode 100644 examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.stdout create mode 100644 examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.exit create mode 100644 examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.stderr create mode 100644 examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.stdout diff --git a/examples/0146-types-comptime-count-matrix.sx b/examples/0146-types-comptime-count-matrix.sx new file mode 100644 index 0000000..5215415 --- /dev/null +++ b/examples/0146-types-comptime-count-matrix.sx @@ -0,0 +1,68 @@ +// The comptime-int COUNT surface is uniform: every count consumer — array +// dimension (direct `[N]T` and via type alias), `Vector` lane, generic +// value-param (struct AND type-fn binder), and `inline for 0..N` — folds the +// SAME leaf forms to the SAME value through one shared evaluator +// (`program_index.evalConstIntExpr` / `moduleConstInt`). The leaf forms +// exercised here: untyped int const (`M`), a named const with an EXPRESSION RHS +// (`N :: M + 1`), a typed-int const (`S : s64 : 5`), an integral float const +// (`F :: 4.0` ≡ 4), and an ALIASED integer constraint (`Count :: u32`, +// `Small :: s8`) on a value-param. +// +// Regression (issue 0083): two cells of this surface diverged from the rest. +// (1) A named const whose RHS is an expression (`N :: M + 1`) did not fold as a +// count ("not a compile-time integer constant") — `moduleConstInt` read only a +// literal RHS; it now folds the RHS through the shared `evalConstIntExpr`. (2) An +// aliased integer constraint (`$K: Count`) bypassed the value-param range gate, +// which only matched builtin constraint names; the constraint now resolves to +// its underlying builtin before range-checking, so `$K: Count` behaves exactly +// like `$K: u32`. +#import "modules/std.sx"; + +M :: 2; // untyped int const +N :: M + 1; // named const, EXPRESSION RHS (== 3) +S : s64 : 5; // typed-int const +KU : u32 : 3; // typed-u32 const +F :: 4.0; // integral float const (== 4) +Count :: u32; // integer ALIAS — value-param constraint +Small :: s8; // integer ALIAS — value-param constraint + +ArrN :: [N]s64; // array dim via alias: expression const (3) +ArrF :: [F]s64; // array dim via alias: integral float (4) +ArrS :: [S]s64; // array dim via alias: typed const (5) + +Buf :: struct ($K: u32, $T: Type) { data: [K]T; } +BufC :: struct ($K: Count, $T: Type) { data: [K]T; } // ALIASED u32 constraint +BufS :: struct ($K: Small, $T: Type) { data: [K]T; } // ALIASED s8 constraint + +Make :: ($K: u32, $T: Type) -> Type { return [K]T; } // type-fn value-param + +main :: () { + // array dimension — DIRECT + a : [N]s64 = ---; a[0] = 7; a[2] = 9; + print("dim.direct.expr: len={} a0={} a2={}\n", a.len, a[0], a[2]); + f : [F]s64 = ---; f[3] = 40; + print("dim.direct.float: len={} f3={}\n", f.len, f[3]); + + // array dimension — via type ALIAS + aa : ArrN = ---; aa[2] = 99; print("dim.alias.expr: len={} aa2={}\n", aa.len, aa[2]); + af : ArrF = ---; print("dim.alias.float: len={}\n", af.len); + az : ArrS = ---; print("dim.alias.typed: len={}\n", az.len); + + // Vector lane — expression const (3) and integral float (4) + v3 : Vector(N, f32) = .[1.0, 2.0, 3.0]; + print("lane.expr3: {} {} {}\n", v3.x, v3.y, v3.z); + v4 : Vector(F, f32) = .[1.0, 2.0, 3.0, 4.0]; + print("lane.float4: {}\n", v4.w); + + // generic value-param — struct binder: expr const, aliased u32, aliased s8 + bn : Buf(N, s64) = ---; bn.data[2] = 30; print("vp.struct.expr: len={} v={}\n", bn.data.len, bn.data[2]); + bc : BufC(KU, s64) = ---; bc.data[2] = 31; print("vp.struct.alias.u32: len={} v={}\n", bc.data.len, bc.data[2]); + bs : BufS(4, s64) = ---; bs.data[3] = 32; print("vp.struct.alias.s8: len={} v={}\n", bs.data.len, bs.data[3]); + + // generic value-param — type-fn binder: expr const + mk : Make(N, s64) = ---; mk[2] = 33; print("vp.typefn.expr: len={} v={}\n", mk.len, mk[2]); + + // inline-for bound — expr const (3) and integral float (4) + s := 0; inline for 0..N: (i) { s += i; } print("for.expr: {}\n", s); // 0+1+2 = 3 + t := 0; inline for 0..F: (i) { t += i; } print("for.float: {}\n", t); // 0+1+2+3 = 6 +} diff --git a/examples/1135-diagnostics-value-param-alias-constraint-overflow.sx b/examples/1135-diagnostics-value-param-alias-constraint-overflow.sx new file mode 100644 index 0000000..9812c67 --- /dev/null +++ b/examples/1135-diagnostics-value-param-alias-constraint-overflow.sx @@ -0,0 +1,23 @@ +// A generic value-param arg that does not fit the param's declared integer type +// is a hard error even when that type is reached through a type ALIAS +// (`$K: Count` where `Count :: u32`, `$K: Small` where `Small :: s8`) — a clean +// diagnostic + non-zero exit, NOT a silent truncating bind. +// +// Regression (issue 0083): the value-param range gate matched only BUILTIN +// constraint names, so an aliased constraint slipped past `intTypeRange` and +// `Box(5_000_000_000)` with `$K: Count` compiled and bound a truncated value. +// The constraint now resolves to its underlying builtin (`Count` → u32, +// `Small` → s8) before range-checking, so an aliased integer constraint behaves +// exactly like the builtin it names — at both the struct and type-fn binders. +#import "modules/std.sx"; + +Count :: u32; +Small :: s8; +Box :: struct ($K: Count) { value: s64; } +Tiny :: struct ($K: Small) { value: s64; } + +main :: () { + b : Box(5000000000) = ---; + t : Tiny(300) = ---; + print("unreachable {} {}\n", b.value, t.value); +} diff --git a/examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx b/examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx new file mode 100644 index 0000000..8b277ba --- /dev/null +++ b/examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx @@ -0,0 +1,22 @@ +// A DIRECT array dimension that is genuinely not a compile-time integer (a +// runtime call) is a hard error — ONE clean diagnostic + non-zero exit. Crucially +// it must NOT fabricate a length and must NOT crash later in lowering: the bad +// var is used downstream (element store + read, `.len`), and lowering has to bail +// gracefully on the `.unresolved` type rather than `@panic` in `sizeOf` or pile +// on cascade errors. +// +// Regression (issue 0083): the stateful `resolveArrayLen` emitted the diagnostic +// then `return 0` — fabricating a 0-length array (0-byte alloca, OOB access) to +// dodge the `sizeOf` panic. It now returns null → the `.unresolved` sentinel; the +// binding's lowering bails on it (a field access on an already-diagnosed +// `.unresolved` value stays silent), so the single real diagnostic aborts the +// build with no fabrication and no panic. +#import "modules/std.sx"; + +get :: () -> s64 { return 5; } + +main :: () { + a : [get()]s64 = ---; + a[0] = 7; + print("unreachable: {} {}\n", a.len, a[0]); +} diff --git a/examples/expected/0146-types-comptime-count-matrix.exit b/examples/expected/0146-types-comptime-count-matrix.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0146-types-comptime-count-matrix.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0146-types-comptime-count-matrix.stderr b/examples/expected/0146-types-comptime-count-matrix.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0146-types-comptime-count-matrix.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0146-types-comptime-count-matrix.stdout b/examples/expected/0146-types-comptime-count-matrix.stdout new file mode 100644 index 0000000..fc5b94b --- /dev/null +++ b/examples/expected/0146-types-comptime-count-matrix.stdout @@ -0,0 +1,13 @@ +dim.direct.expr: len=3 a0=7 a2=9 +dim.direct.float: len=4 f3=40 +dim.alias.expr: len=3 aa2=99 +dim.alias.float: len=4 +dim.alias.typed: len=5 +lane.expr3: 1.000000 2.000000 3.000000 +lane.float4: 4.000000 +vp.struct.expr: len=3 v=30 +vp.struct.alias.u32: len=3 v=31 +vp.struct.alias.s8: len=4 v=32 +vp.typefn.expr: len=3 v=33 +for.expr: 3 +for.float: 6 diff --git a/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.exit b/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.stderr b/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.stderr new file mode 100644 index 0000000..1954810 --- /dev/null +++ b/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.stderr @@ -0,0 +1,11 @@ +error: value 5000000000 does not fit in u32 parameter K + --> examples/1135-diagnostics-value-param-alias-constraint-overflow.sx:20:13 + | +20 | b : Box(5000000000) = ---; + | ^^^^^^^^^^ + +error: value 300 does not fit in s8 parameter K + --> examples/1135-diagnostics-value-param-alias-constraint-overflow.sx:21:14 + | +21 | t : Tiny(300) = ---; + | ^^^ diff --git a/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.stdout b/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1135-diagnostics-value-param-alias-constraint-overflow.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.exit b/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.stderr b/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.stderr new file mode 100644 index 0000000..652c701 --- /dev/null +++ b/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.stderr @@ -0,0 +1,5 @@ +error: array dimension must be a compile-time integer constant + --> examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx:19:10 + | +19 | a : [get()]s64 = ---; + | ^^^^^ diff --git a/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.stdout b/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1136-diagnostics-array-dim-nonconst-direct-no-crash.stdout @@ -0,0 +1 @@ + diff --git a/examples/expected/1502-vectors-runtime-lane-not-const.stderr b/examples/expected/1502-vectors-runtime-lane-not-const.stderr index d1312de..c66fcc1 100644 --- a/examples/expected/1502-vectors-runtime-lane-not-const.stderr +++ b/examples/expected/1502-vectors-runtime-lane-not-const.stderr @@ -3,9 +3,3 @@ error: Vector lane count must be a positive compile-time integer constant | 14 | v : Vector(lanes(), f32) = ---; | ^^^^^^^ - -error: field 'x' not found on type 'unresolved' - --> examples/1502-vectors-runtime-lane-not-const.sx:15:32 - | -15 | print("unreachable: {}\n", v.x); - | ^^^ diff --git a/examples/expected/1503-vectors-oversized-lane-not-u32.stderr b/examples/expected/1503-vectors-oversized-lane-not-u32.stderr index 45034c4..c182f19 100644 --- a/examples/expected/1503-vectors-oversized-lane-not-u32.stderr +++ b/examples/expected/1503-vectors-oversized-lane-not-u32.stderr @@ -3,9 +3,3 @@ error: Vector lane count 5000000000 does not fit in u32 | 13 | v : Vector(5000000000, f32) = ---; | ^^^^^^^^^^ - -error: field 'x' not found on type 'unresolved' - --> examples/1503-vectors-oversized-lane-not-u32.sx:14:32 - | -14 | print("unreachable: {}\n", v.x); - | ^^^ diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index b6b9df5..2b4ae7c 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -167,6 +167,34 @@ > `examples/1132-diagnostics-array-dim-non-integral-float.sx`, > `examples/1133-diagnostics-array-dim-negative-float.sx`, > `examples/1134-diagnostics-value-param-u32-overflow.sx`. +> +> **Convergence — the last three count-surface cells (attempt 9).** Three +> adjacent cells of the SAME shared count surface still diverged. (1) An ALIASED +> integer constraint (`Count :: u32`; `$K: Count`) bypassed the value-param range +> gate — only BUILTIN constraint names matched `intTypeRange`, so +> `Box(5_000_000_000)` with `$K: Count` compiled and bound a truncated value. The +> gate (`Lowering.resolveValueParamArg`, shared by BOTH binders — struct + +> type-fn) now resolves the constraint to its underlying builtin +> (`canonicalIntConstraintName`: `Count` → u32, `Small` → s8) before +> range-checking, so an aliased integer constraint behaves exactly like the +> builtin it names. (2) A named const with an EXPRESSION RHS (`M :: 2; N :: M + 1`) +> did not fold as a count — `program_index.moduleConstInt` read only a LITERAL RHS +> node. It now folds every const's RHS through the shared `evalConstIntExpr` +> (cycle-guarded so `N :: N` / mutual cycles fold to null, not a stack overflow), +> and scanDecls pass-0 pre-registers expression-RHS consts; so `N :: M + 1` == 3 +> at every count consumer (dim direct + alias, Vector lane, value-param struct + +> type-fn, `inline for`). (3) The stateful `Lowering.resolveArrayLen` STILL +> fabricated length 0 after a failed fold; it now returns null → the `.unresolved` +> sentinel (no fabrication), and the binding's lowering bails on it cleanly — a +> field access on an already-diagnosed `.unresolved` value stays silent +> (`emitFieldError`), so a failed-fold dim emits ONE clean diagnostic and never +> reaches the `sizeOf` panic. Files: `src/ir/program_index.zig` (+`.test.zig`), +> `src/ir/lower.zig`. Regressions: `examples/0146-types-comptime-count-matrix.sx` +> (the full positive matrix — every consumer × representative leaf form), +> `examples/1135-diagnostics-value-param-alias-constraint-overflow.sx` (aliased +> u32 + s8 overflow), `examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx` +> (direct non-const dim halts cleanly, no fabrication / panic); the cascade +> cleanup also tightened `examples/1502`/`1503` to one diagnostic each. ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index d781131..5d40114 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -673,6 +673,13 @@ pub const Lowering = struct { switch (cd.value.data) { .int_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .s64 }) catch {}, .float_literal => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .f64 }) catch {}, + // A const whose RHS is an integer EXPRESSION over other consts + // (`M :: 2; N :: M + 1`) is itself a usable count: register it so + // `moduleConstInt` can fold the RHS through `evalConstIntExpr` + // (issue 0083). Placeholder `.s64` type — the count consumers read + // only the value; if the expression doesn't fold (references a + // non-const), `moduleConstInt` yields null and the use diagnoses. + .binary_op, .unary_op => self.program_index.module_const_map.put(cd.name, .{ .value = cd.value, .ty = .s64 }) catch {}, else => {}, } } @@ -11676,13 +11683,17 @@ pub const Lowering = struct { if (result == .ok) return result.ok; // A non-const / oversized / negative dim is a hard error. Emit the // shared diagnostic (single wording source — `program_index.reportDimError`, - // also used by the stateless alias path so the two cannot diverge), then - // return a harmless `0` so body lowering finishes without touching the - // `.unresolved` sentinel (which would `@panic` in `sizeOf` mid-lowering, - // before the diagnostic surfaces). The diagnostic — not the returned - // length — guarantees no garbage ships (issue 0083). + // also used by the stateless alias path so the two cannot diverge) and + // return null so `resolveCompound` yields the `.unresolved` sentinel — NO + // fabricated length (issue 0083: a `0` here gives a 0-byte alloca and OOB + // element access). Lowering the binding never computes the failed type's + // size: `alloca` records the type but defers `sizeOf` to LLVM emission, + // which the emitted diagnostic pre-empts via `hasErrors()`, and a + // downstream use of the `.unresolved`-typed value is poison-suppressed (a + // field access stays silent — `emitFieldError`). So the failure surfaces + // as ONE clean diagnostic and never reaches the `sizeOf` panic. if (self.diagnostics) |d| program_index_mod.reportDimError(d, len_node.span, result); - return 0; + return null; } /// Leaf-name lookup for the shared dimension evaluator: a name bound to a @@ -11874,7 +11885,14 @@ pub const Lowering = struct { /// counts" holds; any other integer type range-checks against /// `program_index.intTypeRange`; an unrecognised type folds without bounding. fn resolveValueParamArg(self: *Lowering, arg_node: *const Node, param_name: []const u8, type_name: ?[]const u8) ?i64 { - if (type_name) |tn| { + // Resolve an ALIASED integer constraint (`$K: Count` where `Count :: u32`, + // `$K: Small` where `Small :: s8`) to its underlying builtin so the range + // gate below treats it exactly like `$K: u32` / `$K: s8` (issue 0083 — an + // alias previously slipped past `intTypeRange`, so `Box(5_000_000_000)` + // with `$K: Count` bound a truncated value). A non-integer / unrecognised + // constraint yields null → no range bound (fold only), as before. + const tn_canon: ?[]const u8 = if (type_name) |tn| self.canonicalIntConstraintName(tn) else null; + if (tn_canon) |tn| { if (std.mem.eql(u8, tn, "u32")) { switch (program_index_mod.foldDimU32(arg_node, self, 0)) { .ok => |n| return n, @@ -11897,7 +11915,7 @@ pub const Lowering = struct { self.diagValueParamNotConst(arg_node, param_name); return null; }; - if (type_name) |tn| { + if (tn_canon) |tn| { if (program_index_mod.intTypeRange(tn)) |r| { if (v < r.min or v > r.max) { self.diagValueParamRange(arg_node, param_name, tn, v); @@ -11908,6 +11926,23 @@ pub const Lowering = struct { return v; } + /// Resolve a generic value-param constraint type NAME to its canonical builtin + /// integer type name, chasing a type alias (`Count :: u32` → "u32", + /// `Small :: s8` → "s8") so an ALIASED integer constraint range-checks exactly + /// like the builtin it names. Returns the name unchanged when it is already a + /// builtin integer; null when it isn't an integer type (directly or via alias) + /// — the caller then folds without a range bound rather than guessing. The + /// alias map + type table are the same single sources every other resolver + /// reads, so this can't diverge from how the alias is laid out elsewhere. + fn canonicalIntConstraintName(self: *Lowering, name: []const u8) ?[]const u8 { + if (program_index_mod.intTypeRange(name) != null) return name; + if (self.program_index.type_alias_map.get(name)) |tid| { + const canon = self.module.types.typeName(tid); + if (program_index_mod.intTypeRange(canon) != null) return canon; + } + return null; + } + fn diagValueParamNotConst(self: *Lowering, arg_node: *const Node, param_name: []const u8) void { if (self.diagnostics) |d| d.addFmt(.err, arg_node.span, "generic value parameter '{s}' must be a compile-time integer constant", .{param_name}); @@ -14104,9 +14139,18 @@ pub const Lowering = struct { } fn emitFieldError(self: *Lowering, obj_ty: TypeId, field: []const u8, span: ast.Span) Ref { - if (self.diagnostics) |diags| { - const ty_name = self.formatTypeName(obj_ty); - diags.addFmt(.err, span, "field '{s}' not found on type '{s}'", .{ field, ty_name }); + // A field access on an already-`.unresolved` object is a cascade from an + // upstream type-resolution failure that was ALREADY diagnosed (e.g. an + // unresolvable / oversized array dimension — issue 0083). The + // `.unresolved` sentinel never exists without an accompanying error, so + // piling a second "field not found on unresolved" onto the real one is + // pure noise; stay silent and return a placeholder so lowering finishes + // and `hasErrors()` aborts the build on the genuine diagnostic. + if (obj_ty != .unresolved) { + if (self.diagnostics) |diags| { + const ty_name = self.formatTypeName(obj_ty); + diags.addFmt(.err, span, "field '{s}' not found on type '{s}'", .{ field, ty_name }); + } } return self.emitPlaceholder(field); } diff --git a/src/ir/program_index.test.zig b/src/ir/program_index.test.zig index b087b69..fc3013e 100644 --- a/src/ir/program_index.test.zig +++ b/src/ir/program_index.test.zig @@ -214,6 +214,53 @@ test "floatToIntExact accepts integral floats, rejects the rest" { try std.testing.expect(f(1.0e30) == null); } +test "moduleConstInt folds expression-RHS consts and rejects cycles" { + var map = std.StringHashMap(pi.ModuleConstInfo).init(std.testing.allocator); + defer map.deinit(); + + // M :: 2 (literal), N :: M + 1 (expression), P :: N * 2 (expression over an + // expression const), F :: 4.0 (integral float), G :: 4.5 (fractional). + var m_val = nLit(2); + var m_id = nIdent("M"); + var one = nLit(1); + var n_val = nBin(.add, &m_id, &one); + var n_id = nIdent("N"); + var two = nLit(2); + var p_val = nBin(.mul, &n_id, &two); + var f_val = nFloat(4.0); + var g_val = nFloat(4.5); + + try map.put("M", .{ .value = &m_val, .ty = .s64 }); + try map.put("N", .{ .value = &n_val, .ty = .s64 }); + try map.put("P", .{ .value = &p_val, .ty = .s64 }); + try map.put("F", .{ .value = &f_val, .ty = .f64 }); + try map.put("G", .{ .value = &g_val, .ty = .f64 }); + + try std.testing.expectEqual(@as(?i64, 2), pi.moduleConstInt(&map, "M")); + try std.testing.expectEqual(@as(?i64, 3), pi.moduleConstInt(&map, "N")); + try std.testing.expectEqual(@as(?i64, 6), pi.moduleConstInt(&map, "P")); + try std.testing.expectEqual(@as(?i64, 4), pi.moduleConstInt(&map, "F")); + try std.testing.expect(pi.moduleConstInt(&map, "G") == null); + try std.testing.expect(pi.moduleConstInt(&map, "absent") == null); + + // A cyclic const has no compile-time integer value, and folding it must not + // recurse forever: mutual `A :: B + 0; B :: A + 0` and self `C :: C + 0` all + // fold to null via the frame-based cycle guard. + var a_id = nIdent("A"); + var b_id = nIdent("B"); + var c_id = nIdent("C"); + var zero = nLit(0); + var a_val = nBin(.add, &b_id, &zero); + var b_val = nBin(.add, &a_id, &zero); + var c_val = nBin(.add, &c_id, &zero); + try map.put("A", .{ .value = &a_val, .ty = .s64 }); + try map.put("B", .{ .value = &b_val, .ty = .s64 }); + try map.put("C", .{ .value = &c_val, .ty = .s64 }); + try std.testing.expect(pi.moduleConstInt(&map, "A") == null); + try std.testing.expect(pi.moduleConstInt(&map, "B") == null); + try std.testing.expect(pi.moduleConstInt(&map, "C") == null); +} + test "evalConstIntExpr folds an integral float literal, halts on a fractional one" { const eval = pi.evalConstIntExpr; const ctx = DimCtx{}; diff --git a/src/ir/program_index.zig b/src/ir/program_index.zig index aebb2dd..4ba77bf 100644 --- a/src/ir/program_index.zig +++ b/src/ir/program_index.zig @@ -63,6 +63,49 @@ pub fn floatToIntExact(v: f64) ?i64 { return @intFromFloat(v); } +/// A frame in the chain of module consts currently being folded by +/// `moduleConstInt`. Stack-allocated (each recursive frame lives on the Zig +/// call stack), so cycle detection needs no allocation. +const ModuleConstFrame = struct { + name: []const u8, + parent: ?*const ModuleConstFrame, +}; + +fn moduleConstFrameContains(frame: ?*const ModuleConstFrame, name: []const u8) bool { + var cur = frame; + while (cur) |c| : (cur = c.parent) { + if (std.mem.eql(u8, c.name, name)) return true; + } + return false; +} + +/// Folding context for a module-const EXPRESSION RHS (`N :: M + 1`): a leaf name +/// resolves to another module const via `moduleConstInt`, recursively, so the +/// SAME shared `evalConstIntExpr` that folds an inline dim expression (`[M + 1]`) +/// also folds an expression hidden behind a const name. `frame` is the chain of +/// const names currently being resolved; a name already on it is a cyclic +/// definition (`N :: N`; `N :: M + 1; M :: N`) — which has no compile-time +/// integer value — so it folds to null (→ the clean "not a compile-time integer +/// constant" diagnostic) rather than recursing forever. No pack arity at module +/// scope, so `lookupPackLen` is always null. +const ModuleConstCtx = struct { + consts: *const std.StringHashMap(ModuleConstInfo), + frame: ?*const ModuleConstFrame, + pub fn lookupDimName(self: ModuleConstCtx, name: []const u8) ?i64 { + return moduleConstIntFramed(self.consts, name, self.frame); + } + pub fn lookupPackLen(_: ModuleConstCtx, _: []const u8) ?i64 { + return null; + } +}; + +fn moduleConstIntFramed(consts: *const std.StringHashMap(ModuleConstInfo), name: []const u8, parent: ?*const ModuleConstFrame) ?i64 { + if (moduleConstFrameContains(parent, name)) return null; + const ci = consts.get(name) orelse return null; + var frame = ModuleConstFrame{ .name = name, .parent = parent }; + return evalConstIntExpr(ci.value, ModuleConstCtx{ .consts = consts, .frame = &frame }); +} + /// A name bound to a module-global integer constant → its value, else null. /// SINGLE source for both array-dimension resolvers — the stateful /// body-lowering path (`Lowering.comptimeIntNamed`) and the stateless @@ -70,17 +113,14 @@ pub fn floatToIntExact(v: f64) ?i64 { /// which named consts a `[N]T` dimension resolves to; if they diverge, an array /// laid out via a type alias (`Arr :: [N]T`, stateless) gets a different length /// than the direct form (`a : [N]T`, stateful) — the issue-0083 miscompile. -/// Untyped (`N :: 16`) and typed (`N : s64 : 16`) consts store an `.int_literal` -/// value node; a float-typed const (`N : f64 : 4.0`, `N :: 4.0`) stores a -/// `.float_literal` and resolves iff its value is an integral float (via -/// `floatToIntExact`) — `4.5` is not an integer → null. +/// Every const's RHS is folded through the shared `evalConstIntExpr`, so an +/// untyped (`N :: 16`) / typed (`N : s64 : 16`) literal, an integral float +/// (`N : f64 : 4.0` → 4, via `floatToIntExact`; `4.5` → null), AND an expression +/// RHS over other consts (`M :: 2; N :: M + 1` → 3) all resolve identically and +/// everywhere a count is accepted. Cyclic consts fold to null (see +/// `ModuleConstCtx`). pub fn moduleConstInt(consts: *const std.StringHashMap(ModuleConstInfo), name: []const u8) ?i64 { - const ci = consts.get(name) orelse return null; - return switch (ci.value.data) { - .int_literal => |lit| lit.value, - .float_literal => |lit| floatToIntExact(lit.value), - else => null, - }; + return moduleConstIntFramed(consts, name, null); } /// Evaluate a constant integer expression to its value. THE single From a7dcb23b70b8896af1e8dd7a02c0c15632bfd8f2 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 14:38:18 +0300 Subject: [PATCH 11/13] fix(ir): poison type-fn binder on failed value-param bind (0083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed value-param bind on a type-returning function (e.g. `MakeC :: ($K: Count, $T: Type) -> Type { return [K]T; }` with `a : MakeC(5_000_000_000, s64)`) emitted its correct range diagnostic but then `instantiateTypeFunction` returned `null`, so `resolveParameterizedWithBindings` fell through to an empty-struct placeholder named after the function. The binding `a` got that placeholder type, so a later `a.len` cascaded a bogus second error `field 'len' not found on type 'MakeC'`. The struct binder (`instantiateGenericStruct`) already returns `.unresolved` here; the type-fn binder now matches it — a failed value-param bind poisons to `.unresolved` instead of `null`, so the caller propagates the diagnosed poison and the existing `emitFieldError` suppression yields one clean diagnostic. Covers every type-fn value-param failure mode: overflow via an aliased constraint, a non-const arg, and an unknown type arg. Regression: examples/1137-diagnostics-value-param-type-fn-no-cascade.sx --- ...gnostics-value-param-type-fn-no-cascade.sx | 27 +++++++++++++++++++ ...ostics-value-param-type-fn-no-cascade.exit | 1 + ...tics-value-param-type-fn-no-cascade.stderr | 17 ++++++++++++ ...tics-value-param-type-fn-no-cascade.stdout | 1 + ...named-const-array-dimension-miscompiled.md | 17 ++++++++++++ src/ir/lower.zig | 9 +++++-- 6 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 examples/1137-diagnostics-value-param-type-fn-no-cascade.sx create mode 100644 examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.exit create mode 100644 examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.stderr create mode 100644 examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.stdout diff --git a/examples/1137-diagnostics-value-param-type-fn-no-cascade.sx b/examples/1137-diagnostics-value-param-type-fn-no-cascade.sx new file mode 100644 index 0000000..4ae3cfb --- /dev/null +++ b/examples/1137-diagnostics-value-param-type-fn-no-cascade.sx @@ -0,0 +1,27 @@ +// A FAILED value-param bind on a type-RETURNING FUNCTION must emit exactly its +// own diagnostic and NOT cascade a bogus `field '…' not found on ''` when the +// binding is later field-accessed. The type-fn binder must poison the binding to +// `.unresolved` (the diagnosed-poison sentinel) — exactly like the struct binder — +// so the downstream `.len` is suppressed, not reported as a second error. +// +// Regression (issue 0083): the type-fn path (`instantiateTypeFunction`) fell +// through to an empty-struct placeholder named after the function on a failed +// value-param bind, so `a.len` produced a second `field 'len' not found on +// 'MakeC'` error. The struct binder already returned `.unresolved` here; the +// type-fn binder now matches it. Three failure modes, three clean diagnostics, +// zero field cascades: +// - value-param overflow via an aliased integer constraint (`$K: Count`), +// - a non-const value-param arg (`get()`), +// - an unknown TYPE arg (`NoSuchType`) — must still report the unknown type. +#import "modules/std.sx"; + +Count :: u32; +MakeC :: ($K: Count, $T: Type) -> Type { return [K]T; } +get :: () -> u32 { return 4; } + +main :: () { + a : MakeC(5000000000, s64) = ---; + b : MakeC(get(), s64) = ---; + c : MakeC(3, NoSuchType) = ---; + print("unreachable {} {} {}\n", a.len, b.len, c.len); +} diff --git a/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.exit b/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.stderr b/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.stderr new file mode 100644 index 0000000..06633c5 --- /dev/null +++ b/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.stderr @@ -0,0 +1,17 @@ +error: unknown type 'NoSuchType' + --> examples/1137-diagnostics-value-param-type-fn-no-cascade.sx:25:18 + | +25 | c : MakeC(3, NoSuchType) = ---; + | ^^^^^^^^^^ + +error: value 5000000000 does not fit in u32 parameter K + --> examples/1137-diagnostics-value-param-type-fn-no-cascade.sx:23:15 + | +23 | a : MakeC(5000000000, s64) = ---; + | ^^^^^^^^^^ + +error: generic value parameter 'K' must be a compile-time integer constant + --> examples/1137-diagnostics-value-param-type-fn-no-cascade.sx:24:15 + | +24 | b : MakeC(get(), s64) = ---; + | ^^^^^ diff --git a/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.stdout b/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1137-diagnostics-value-param-type-fn-no-cascade.stdout @@ -0,0 +1 @@ + diff --git a/issues/0083-named-const-array-dimension-miscompiled.md b/issues/0083-named-const-array-dimension-miscompiled.md index 2b4ae7c..97ff9ef 100644 --- a/issues/0083-named-const-array-dimension-miscompiled.md +++ b/issues/0083-named-const-array-dimension-miscompiled.md @@ -195,6 +195,23 @@ > u32 + s8 overflow), `examples/1136-diagnostics-array-dim-nonconst-direct-no-crash.sx` > (direct non-const dim halts cleanly, no fabrication / panic); the cascade > cleanup also tightened `examples/1502`/`1503` to one diagnostic each. +> +> **Final convergence — type-fn binder parity (attempt 10).** One last cell of +> the count surface still diverged from the struct binder. A FAILED value-param +> bind on a type-RETURNING FUNCTION (`MakeC :: ($K: Count, $T: Type) -> Type +> { return [K]T; }`; `a : MakeC(5_000_000_000, s64)`) emitted its correct range +> diagnostic, but `instantiateTypeFunction` then returned `null`, so +> `resolveParameterizedWithBindings` fell through to the empty-struct *placeholder* +> named `MakeC`. The binding `a` got that placeholder type, so a downstream +> `a.len` cascaded a bogus second error `field 'len' not found on type 'MakeC'`. +> The struct binder (`instantiateGenericStruct`) already returned `.unresolved` +> here; the type-fn binder now matches it — the failed value-param bind poisons to +> `.unresolved` instead of `null`, so the caller propagates the diagnosed poison +> and the existing `emitFieldError` suppression yields ONE clean diagnostic. Covers +> every type-fn value-param failure mode (overflow via aliased constraint, +> non-const arg, unknown type arg). Files: `src/ir/lower.zig` (one line in +> `instantiateTypeFunction`). Regression: +> `examples/1137-diagnostics-value-param-type-fn-no-cascade.sx`. ## Symptom A fixed array whose dimension is a module-global integer constant (`N :: 16; diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 5d40114..ddb0210 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -12183,9 +12183,14 @@ pub const Lowering = struct { name_parts.appendSlice(self.alloc, tname) catch {}; } else { // Value param (e.g., $N: u32) — fold to a compile-time integer - // and range-check against its declared type. + // and range-check against its declared type. A failed bind has + // already diagnosed itself, so poison to `.unresolved` rather + // than `null`: `null` makes the caller fall through to the + // empty-struct placeholder named after the fn, which then + // cascades a bogus `field not found` on any later access. The + // struct binder (`instantiateGenericStruct`) poisons the same way. const vp_type: ?[]const u8 = if (tp.constraint.data == .type_expr) tp.constraint.data.type_expr.name else null; - const val = self.resolveValueParamArg(args[i], tp.name, vp_type) orelse return null; + const val = self.resolveValueParamArg(args[i], tp.name, vp_type) orelse return .unresolved; cvb.put(tp.name, val) catch {}; var val_buf: [32]u8 = undefined; const val_str = std.fmt.bufPrint(&val_buf, "{d}", .{val}) catch "0"; From 0d29f2c2869839ad143162decb695285b1792438 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 15:17:33 +0300 Subject: [PATCH 12/13] docs(spec): split range bounds from counts; pin inline-for range semantics (0083) specs.md lumped `inline for` / `for` range bounds in with counts (array dimension, Vector lane count, generic value-param count) under the count negative-rejection rule. A range bound is a range ENDPOINT, not a count: negative endpoints are valid and an empty/inverted range runs zero iterations. The compiler already implements this correctly (Agra ruling: spec-text bug, no code change). - specs.md: counts and range bounds are now described separately. Counts reject negatives; bounds accept any compile-time integer (negatives valid, integral floats fold) but still reject a non-integral float because the loop cursor must be an integer. - examples/0612-comptime-inline-for-range-bounds.sx: `inline for -2..1` and `for -2..1` both sum -3; `inline for 0..(-2.0)` runs zero iterations (empty range). Runtime/comptime parity asserted. - examples/1138-diagnostics-inline-for-non-integral-bound.sx: a non-integral float bound `inline for 0..4.5` is a clean diagnostic, exit 1 (must-be-integer still applies to bounds). Count consumers (1132/1133/1134/1135) unchanged and green. --- .../0612-comptime-inline-for-range-bounds.sx | 24 +++++++++++++++++++ ...agnostics-inline-for-non-integral-bound.sx | 14 +++++++++++ ...0612-comptime-inline-for-range-bounds.exit | 1 + ...12-comptime-inline-for-range-bounds.stderr | 1 + ...12-comptime-inline-for-range-bounds.stdout | 3 +++ ...nostics-inline-for-non-integral-bound.exit | 1 + ...stics-inline-for-non-integral-bound.stderr | 5 ++++ ...stics-inline-for-non-integral-bound.stdout | 1 + specs.md | 19 +++++++++++---- 9 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 examples/0612-comptime-inline-for-range-bounds.sx create mode 100644 examples/1138-diagnostics-inline-for-non-integral-bound.sx create mode 100644 examples/expected/0612-comptime-inline-for-range-bounds.exit create mode 100644 examples/expected/0612-comptime-inline-for-range-bounds.stderr create mode 100644 examples/expected/0612-comptime-inline-for-range-bounds.stdout create mode 100644 examples/expected/1138-diagnostics-inline-for-non-integral-bound.exit create mode 100644 examples/expected/1138-diagnostics-inline-for-non-integral-bound.stderr create mode 100644 examples/expected/1138-diagnostics-inline-for-non-integral-bound.stdout diff --git a/examples/0612-comptime-inline-for-range-bounds.sx b/examples/0612-comptime-inline-for-range-bounds.sx new file mode 100644 index 0000000..8857e93 --- /dev/null +++ b/examples/0612-comptime-inline-for-range-bounds.sx @@ -0,0 +1,24 @@ +// An `inline for` / `for` range bound is a range ENDPOINT, not a count, so the +// count negative-rejection rule does NOT apply to it: negative endpoints are +// valid and an empty/inverted range simply runs zero iterations. +// +// Regression (F0.4 attempt 11, Agra ruling): the spec wrongly lumped inline-for +// bounds with counts (array dim / Vector lane / value-param), which reject +// negatives. Bounds are exempt — `inline for -2..1` iterates -2,-1,0 and an +// integral-float empty range `0..(-2.0)` runs zero iterations. Comptime and +// runtime loops must agree. +#import "modules/std.sx"; + +main :: () { + s := 0; + inline for -2..1: (i) { s += i; } + print("inline for -2..1 sum = {}\n", s); // -2 + -1 + 0 = -3 + + r := 0; + for -2..1: (i) { r += i; } + print("for -2..1 sum = {}\n", r); // -2 + -1 + 0 = -3 (runtime parity) + + e := 0; + inline for 0..(-2.0): (i) { e += i; } + print("inline for 0..(-2.0) sum = {}\n", e); // empty range -> 0 iterations +} diff --git a/examples/1138-diagnostics-inline-for-non-integral-bound.sx b/examples/1138-diagnostics-inline-for-non-integral-bound.sx new file mode 100644 index 0000000..71481a3 --- /dev/null +++ b/examples/1138-diagnostics-inline-for-non-integral-bound.sx @@ -0,0 +1,14 @@ +// A NON-integral float (`4.5`) as an `inline for` range bound is a hard error: +// the loop cursor must be a compile-time integer, so only an integral float +// (`4.0`, `-2.0`) folds. Clean diagnostic + non-zero exit. +// +// Regression (F0.4 attempt 11, Agra ruling): range bounds are exempt from the +// count negative-rejection (negatives are valid endpoints), but the +// must-be-integer requirement still applies — `4.5` has no integer value. +#import "modules/std.sx"; + +main :: () { + s := 0; + inline for 0..4.5: (i) { s += i; } + print("unreachable: {}\n", s); +} diff --git a/examples/expected/0612-comptime-inline-for-range-bounds.exit b/examples/expected/0612-comptime-inline-for-range-bounds.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0612-comptime-inline-for-range-bounds.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0612-comptime-inline-for-range-bounds.stderr b/examples/expected/0612-comptime-inline-for-range-bounds.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0612-comptime-inline-for-range-bounds.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0612-comptime-inline-for-range-bounds.stdout b/examples/expected/0612-comptime-inline-for-range-bounds.stdout new file mode 100644 index 0000000..d331b45 --- /dev/null +++ b/examples/expected/0612-comptime-inline-for-range-bounds.stdout @@ -0,0 +1,3 @@ +inline for -2..1 sum = -3 +for -2..1 sum = -3 +inline for 0..(-2.0) sum = 0 diff --git a/examples/expected/1138-diagnostics-inline-for-non-integral-bound.exit b/examples/expected/1138-diagnostics-inline-for-non-integral-bound.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1138-diagnostics-inline-for-non-integral-bound.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1138-diagnostics-inline-for-non-integral-bound.stderr b/examples/expected/1138-diagnostics-inline-for-non-integral-bound.stderr new file mode 100644 index 0000000..23039e6 --- /dev/null +++ b/examples/expected/1138-diagnostics-inline-for-non-integral-bound.stderr @@ -0,0 +1,5 @@ +error: inline for: range end is not a compile-time integer + --> examples/1138-diagnostics-inline-for-non-integral-bound.sx:12:19 + | +12 | inline for 0..4.5: (i) { s += i; } + | ^^^ diff --git a/examples/expected/1138-diagnostics-inline-for-non-integral-bound.stdout b/examples/expected/1138-diagnostics-inline-for-non-integral-bound.stdout new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1138-diagnostics-inline-for-non-integral-bound.stdout @@ -0,0 +1 @@ + diff --git a/specs.md b/specs.md index 4958ae5..183295f 100644 --- a/specs.md +++ b/specs.md @@ -651,11 +651,20 @@ Arrays can also be constructed programmatically with the `Array` builtin: MyArr :: Array(5, s32); // equivalent to [5]s32 ``` -An array dimension — and likewise a `Vector` lane count, a generic value-param -count, and an `inline for` bound — accepts any compile-time numeric constant -whose value is a positive integral number. An integral float (`4.0`, or a -float-typed const `N : f64 : 4.0`) folds to its integer (`[4.0]s64` ≡ `[4]s64`); -a non-integral float (`4.5`) or a negative value is rejected. +A **count** — an array dimension, a `Vector` lane count, or a generic +value-param count — accepts any compile-time numeric constant whose value is a +positive integral number. An integral float (`4.0`, or a float-typed const +`N : f64 : 4.0`) folds to its integer (`[4.0]s64` ≡ `[4]s64`); a non-integral +float (`4.5`) or a negative value is rejected. + +A **range bound** — the start/end of an `inline for` or `for` range — is a +range *endpoint*, not a count, so the count rules above do not apply. A bound +accepts any compile-time **integer**, including a negative one; an integral +float (`-2.0`) folds to its integer. A non-integral float (`4.5`) is still +rejected, because the loop cursor must be a compile-time integer. Negative +endpoints are valid: `inline for -2..1` iterates `-2, -1, 0`. An empty or +inverted range (start ≥ end, e.g. `0..(-2.0)`) simply runs zero iterations +rather than being an error. ### Slice Types A slice `[]T` is a fat pointer `{ptr, i64}` referencing a contiguous sequence of `T` elements. Same runtime layout as `string`. From c01ece5483f8707b79b585fe7abebb8380736fd7 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 4 Jun 2026 15:32:48 +0300 Subject: [PATCH 13/13] docs(spec): make the count zero-rule context-dependent per consumer (0083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The count description claimed every count must be "positive integral", which is wrong: zero is context-dependent. Verified at HEAD — an array dimension (`[0]s64`) and a generic value-param count (`Box(0)`, $N:u32) both accept zero as a length-0 instantiation, while a `Vector` lane count stays strictly positive (`Vector(0,f32)` rejected). Negatives are rejected for array dims and unsigned value-params, but a signed value-param accepts a negative; only the integral requirement (folds 4.0, rejects 4.5) is common to all three. Split the count paragraph into per-consumer bullets stating the exact range each accepts. Range-bound paragraph unchanged. Pin the zero contrast with examples 0147 (array-dim + value-param zero accepted) and 1505 (Vector zero-lane rejected). No compiler-code change. --- examples/0147-types-zero-count-context.sx | 19 +++++++++++++++ examples/1505-vectors-zero-lane-rejected.sx | 15 ++++++++++++ .../0147-types-zero-count-context.exit | 1 + .../0147-types-zero-count-context.stderr | 0 .../0147-types-zero-count-context.stdout | 2 ++ .../1505-vectors-zero-lane-rejected.exit | 1 + .../1505-vectors-zero-lane-rejected.stderr | 5 ++++ .../1505-vectors-zero-lane-rejected.stdout | 0 specs.md | 23 +++++++++++++++---- 9 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 examples/0147-types-zero-count-context.sx create mode 100644 examples/1505-vectors-zero-lane-rejected.sx create mode 100644 examples/expected/0147-types-zero-count-context.exit create mode 100644 examples/expected/0147-types-zero-count-context.stderr create mode 100644 examples/expected/0147-types-zero-count-context.stdout create mode 100644 examples/expected/1505-vectors-zero-lane-rejected.exit create mode 100644 examples/expected/1505-vectors-zero-lane-rejected.stderr create mode 100644 examples/expected/1505-vectors-zero-lane-rejected.stdout diff --git a/examples/0147-types-zero-count-context.sx b/examples/0147-types-zero-count-context.sx new file mode 100644 index 0000000..ea3ce77 --- /dev/null +++ b/examples/0147-types-zero-count-context.sx @@ -0,0 +1,19 @@ +// Zero is a context-dependent count. An array dimension and a generic +// value-param count both ACCEPT zero — `[0]T` is a valid empty (zero-length) +// array, and `Box(0)` is a length-0 instantiation. (A `Vector` lane count +// rejects zero — see 1505.) This pins the zero-accepting half of the +// context-dependent count rule documented in specs.md (Array Types). +// +// Regression (F0.4 attempt 12): the spec previously claimed every count must be +// "positive integral", which wrongly implied `[0]T` / `Box(0)` are illegal. +#import "modules/std.sx"; + +Box :: struct($N: u32) { items: [N]s64; } + +main :: () { + a : [0]s64 = ---; + print("array_dim={}\n", a.len); + + b : Box(0) = ---; + print("value_param={}\n", b.items.len); +} diff --git a/examples/1505-vectors-zero-lane-rejected.sx b/examples/1505-vectors-zero-lane-rejected.sx new file mode 100644 index 0000000..5c2e164 --- /dev/null +++ b/examples/1505-vectors-zero-lane-rejected.sx @@ -0,0 +1,15 @@ +// A zero `Vector` lane count is rejected — a vector must have at least one lane +// (strictly positive). Contrast with an array dimension / value-param count, +// where zero is a valid length-0 instantiation (see 0147). This pins the +// zero-rejecting half of the context-dependent count rule (specs.md, Array +// Types). +// +// Regression (F0.4 attempt 12): the spec now states the zero rule per consumer; +// the `Vector` lane count stays strictly positive while array dims / value-param +// counts accept zero. +#import "modules/std.sx"; + +main :: () { + v : Vector(0, f32) = ---; + print("unreachable: {}\n", v.x); +} diff --git a/examples/expected/0147-types-zero-count-context.exit b/examples/expected/0147-types-zero-count-context.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0147-types-zero-count-context.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0147-types-zero-count-context.stderr b/examples/expected/0147-types-zero-count-context.stderr new file mode 100644 index 0000000..e69de29 diff --git a/examples/expected/0147-types-zero-count-context.stdout b/examples/expected/0147-types-zero-count-context.stdout new file mode 100644 index 0000000..6be2c2b --- /dev/null +++ b/examples/expected/0147-types-zero-count-context.stdout @@ -0,0 +1,2 @@ +array_dim=0 +value_param=0 diff --git a/examples/expected/1505-vectors-zero-lane-rejected.exit b/examples/expected/1505-vectors-zero-lane-rejected.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/examples/expected/1505-vectors-zero-lane-rejected.exit @@ -0,0 +1 @@ +1 diff --git a/examples/expected/1505-vectors-zero-lane-rejected.stderr b/examples/expected/1505-vectors-zero-lane-rejected.stderr new file mode 100644 index 0000000..50c38fc --- /dev/null +++ b/examples/expected/1505-vectors-zero-lane-rejected.stderr @@ -0,0 +1,5 @@ +error: Vector lane count must be a positive compile-time integer constant + --> examples/1505-vectors-zero-lane-rejected.sx:13:16 + | +13 | v : Vector(0, f32) = ---; + | ^ diff --git a/examples/expected/1505-vectors-zero-lane-rejected.stdout b/examples/expected/1505-vectors-zero-lane-rejected.stdout new file mode 100644 index 0000000..e69de29 diff --git a/specs.md b/specs.md index 183295f..5fbf70f 100644 --- a/specs.md +++ b/specs.md @@ -651,11 +651,24 @@ Arrays can also be constructed programmatically with the `Array` builtin: MyArr :: Array(5, s32); // equivalent to [5]s32 ``` -A **count** — an array dimension, a `Vector` lane count, or a generic -value-param count — accepts any compile-time numeric constant whose value is a -positive integral number. An integral float (`4.0`, or a float-typed const -`N : f64 : 4.0`) folds to its integer (`[4.0]s64` ≡ `[4]s64`); a non-integral -float (`4.5`) or a negative value is rejected. +A **count** is a compile-time integer used as an array dimension, a `Vector` +lane count, or a generic value-param count. Every count must be **integral**: an +integral float (`4.0`, or a float-typed const `N : f64 : 4.0`) folds to its +integer (`[4.0]s64` ≡ `[4]s64`), while a non-integral float (`4.5`) is rejected. +The accepted *range* of a count is **context-dependent** — zero is legal for +some counts and not others: + +- **Array dimension** — any compile-time integer ≥ 0. `[0]T` is a valid empty + (zero-length) array; a negative dimension is rejected ("array dimension must + be non-negative"). +- **Generic value-param count** — bounded by the parameter's declared integer + type. Zero is allowed (`Box(0)`, for `Box :: struct($N: u32)`, is a length-0 + instantiation); a value outside that type's range is rejected (`-1` or + `5_000_000_000` for a `u32` param). A negative count is therefore accepted + only when the declared type is signed. +- **`Vector` lane count** — any compile-time integer ≥ 1 (strictly positive). A + zero-lane or negative vector (`Vector(0, f32)`) is rejected ("Vector lane + count must be a positive compile-time integer constant"). A **range bound** — the start/end of an `inline for` or `for` range — is a range *endpoint*, not a count, so the count rules above do not apply. A bound