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