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 ───────────────────────────────────────────────────