From 538349611ea5f63080ef5e4f33e3e59e91ffffff Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 19 Jun 2026 21:41:07 +0300 Subject: [PATCH] comptime: empty-member types are valid for all kinds; keep never-defined declare rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comptime-constructed type with NO members is now VALID for every kind (empty struct, empty tuple, empty enum, empty tagged_union) — only a bare `declare("X")` placeholder that is never completed by a matching `define` stays rejected (it would panic codegen). - comptime_vm.zig registerTypeVm: drop the blanket "a type with no members is never valid" rejection. The per-kind loops are vacuous for an empty member list and the dup-name checks stay correct. - types.zig TaggedUnionInfo: add `defined: bool = true`. Every real construction (normal unions, error sets, register_type completion) is "defined" by default; only the two declare-PLACEHOLDER sites set it false: comptime_vm.declareNominal and lower/comptime.preregisterForwardTypes. - lower/comptime.checkComptimeTypeResult: reject on `!defined` (never-defined placeholder) instead of `fields.len == 0`, so an explicitly-defined empty union passes through while a never-completed declare is still gated. - types.zig typeSizeBytes(tagged_union): floor the payload area at 8 bytes when no field carries a payload, mirroring the LLVM lowering — fixes a verifySizes panic on an empty/all-void tagged_union (IR sized to tag-only, LLVM laid out tag + [8 x i8]). Tests: - examples/1179: repurposed from "empty enum rejected" (now valid) to the never-defined `declare` case (the remaining rejection); preserves its issue-0140 regression role. - examples/1180 (duplicate variant): still rejected, unchanged output. - examples/0641 (new): construct empty struct/tuple/enum/tagged_union via define/declare; instantiate the constructible ones; exit 0. --- examples/0641-comptime-empty-types-valid.sx | 36 +++++++++++++++++++ ...nostics-comptime-type-construction-bail.sx | 25 +++++++++---- .../0641-comptime-empty-types-valid.exit | 1 + .../0641-comptime-empty-types-valid.stderr | 1 + .../0641-comptime-empty-types-valid.stdout | 1 + ...ics-comptime-type-construction-bail.stderr | 8 ++--- src/ir/comptime_vm.zig | 9 +++-- src/ir/lower/comptime.zig | 13 ++++--- src/ir/types.zig | 14 ++++++++ 9 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 examples/0641-comptime-empty-types-valid.sx create mode 100644 examples/expected/0641-comptime-empty-types-valid.exit create mode 100644 examples/expected/0641-comptime-empty-types-valid.stderr create mode 100644 examples/expected/0641-comptime-empty-types-valid.stdout diff --git a/examples/0641-comptime-empty-types-valid.sx b/examples/0641-comptime-empty-types-valid.sx new file mode 100644 index 00000000..6a04d918 --- /dev/null +++ b/examples/0641-comptime-empty-types-valid.sx @@ -0,0 +1,36 @@ +// A comptime-constructed type with NO members is VALID for every kind: +// - an empty struct `struct {}` and empty tuple `()` are zero-size aggregates +// you can instantiate (`.{}`), +// - an empty enum and an empty tagged_union are valid uninhabited / zero-member +// types — legitimate to NAME and reference even though they have no +// constructible value (no variant to construct). +// +// This mirrors normal sx, where `struct {}` and `enum {}` already codegen fine; +// the metatype `define`/`declare` path now agrees (the old blanket +// "a type with no members is never valid" rejection is gone). +// +// The ONLY thing still rejected on this path is a bare `declare("X")` that is +// never completed by a matching `define` — an INCOMPLETE forward slot that would +// panic codegen. That case is exercised by examples/1179. +#import "modules/std.sx"; +#import "modules/std/meta.sx"; + +// Explicitly-defined empty types of every kind. +EmptyStruct :: define(declare("EmptyStruct"), .struct(.{ fields = .[] })); +EmptyTuple :: define(declare("EmptyTuple"), .tuple(.{ elements = .[] })); +EmptyEnum :: define(declare("EmptyEnum"), .enum(.{ variants = .[] })); +// An empty tagged_union (kind 3): no variants, but a valid named type. (The +// `define` DSL maps an all-void variant set to a payloadless enum, so reach for +// the register_type primitive directly to mint a 0-variant tagged_union.) +EmptyUnion :: register_type(declare("EmptyUnion"), 3, .[]); + +main :: () -> i32 { + // Instantiate the constructible ones. + s : EmptyStruct = .{}; + t : EmptyTuple = .{}; + _ = s; + _ = t; + // EmptyEnum / EmptyUnion are uninhabited — valid as types, no value to make. + print("empty struct/tuple/enum/tagged_union are all valid\n"); + return 0; +} diff --git a/examples/1179-diagnostics-comptime-type-construction-bail.sx b/examples/1179-diagnostics-comptime-type-construction-bail.sx index 09a2e05e..91859a6b 100644 --- a/examples/1179-diagnostics-comptime-type-construction-bail.sx +++ b/examples/1179-diagnostics-comptime-type-construction-bail.sx @@ -1,9 +1,15 @@ -// A comptime type construction (declare/define, reflection) that bails in the -// interpreter must surface a build-gating DIAGNOSTIC naming the reason — not +// A comptime type construction (declare/define, reflection) that leaves a type +// INCOMPLETE must surface a build-gating DIAGNOSTIC naming the reason — not // poison the decl to `.unresolved` silently and let that crash at LLVM emission -// or hide behind a downstream cascade. Here `define` is handed an empty variant -// list; the interp bails "enum has no variants", and `evalComptimeType` renders -// that at the construction site (exit 1, no panic). +// or hide behind a downstream cascade. Here `declare("Undefined")` mints a +// forward nominal slot that is NEVER completed by a matching `define(handle, …)`; +// the compiler rejects the incomplete type at its construction site (exit 1, no +// panic). +// +// NOTE: an EXPLICITLY-defined empty type (empty struct/tuple/enum/tagged_union) +// is VALID — see examples/0641. The remaining rejection is purely the +// never-defined `declare` placeholder, which would otherwise panic codegen +// (`verifySizes`: llvm_size != ir_size on an unsized forward slot). // // Regression (issue 0140): before the fix this panicked with "unresolved type // reached LLVM emission" (exit 134), because the interp's bail detail was @@ -11,9 +17,14 @@ #import "modules/std.sx"; #import "modules/std/meta.sx"; -Empty :: define(declare("Empty"), .enum(.{ variants = .[] })); +// Declared but never `define`d — an incomplete forward slot. +mk_undefined :: () -> Type { + return declare("Undefined"); +} + +Undefined :: mk_undefined(); main :: () -> i32 { - e : Empty = ---; + u : Undefined = ---; return 0; } diff --git a/examples/expected/0641-comptime-empty-types-valid.exit b/examples/expected/0641-comptime-empty-types-valid.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/expected/0641-comptime-empty-types-valid.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0641-comptime-empty-types-valid.stderr b/examples/expected/0641-comptime-empty-types-valid.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/expected/0641-comptime-empty-types-valid.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0641-comptime-empty-types-valid.stdout b/examples/expected/0641-comptime-empty-types-valid.stdout new file mode 100644 index 00000000..253bd47c --- /dev/null +++ b/examples/expected/0641-comptime-empty-types-valid.stdout @@ -0,0 +1 @@ +empty struct/tuple/enum/tagged_union are all valid diff --git a/examples/expected/1179-diagnostics-comptime-type-construction-bail.stderr b/examples/expected/1179-diagnostics-comptime-type-construction-bail.stderr index e394acd8..e948dd05 100644 --- a/examples/expected/1179-diagnostics-comptime-type-construction-bail.stderr +++ b/examples/expected/1179-diagnostics-comptime-type-construction-bail.stderr @@ -1,5 +1,5 @@ -error: comptime type construction failed: comptime register_type: a type with no members is never valid - --> examples/1179-diagnostics-comptime-type-construction-bail.sx:14:10 +error: type 'Undefined' is declared but never defined — complete it with define(handle, info) + --> examples/1179-diagnostics-comptime-type-construction-bail.sx:25:14 | -14 | Empty :: define(declare("Empty"), .enum(.{ variants = .[] })); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +25 | Undefined :: mk_undefined(); + | ^^^^^^^^^^^^^^ diff --git a/src/ir/comptime_vm.zig b/src/ir/comptime_vm.zig index fca1b4be..6a081c7a 100644 --- a/src/ir/comptime_vm.zig +++ b/src/ir/comptime_vm.zig @@ -1801,7 +1801,12 @@ pub const Vm = struct { var members = std.ArrayList(NamedMember).empty; defer members.deinit(self.gpa); try self.decodeMemberSlice(table, members_word, slice_ty, &members); - if (members.items.len == 0) return self.failMsg("comptime register_type: a type with no members is never valid"); + // A comptime-constructed type with NO members is VALID for every kind + // (empty struct / tuple / enum / tagged_union). The per-kind loops below + // are vacuous for an empty member list and the dup-name checks stay + // correct. The completion always sets `defined = true`, so the result is + // distinguishable from a never-completed `declare(...)` placeholder + // (which carries `defined = false`). const tbl = @constCast(table); // The slot's nominal identity — accept the forward `tagged_union` from @@ -1853,7 +1858,7 @@ pub const Vm = struct { const tbl = @constCast(table); const name_id = tbl.internString(text); if (tbl.findByName(name_id)) |existing| return existing; - return tbl.internNominal(.{ .tagged_union = .{ .name = name_id, .fields = &.{}, .tag_type = .i64 } }, 0); + return tbl.internNominal(.{ .tagged_union = .{ .name = name_id, .fields = &.{}, .tag_type = .i64, .defined = false } }, 0); } /// Decode a `[]{ name: string, ty: Type }` slice from comptime memory into interned diff --git a/src/ir/lower/comptime.zig b/src/ir/lower/comptime.zig index 213fa78c..acdd074c 100644 --- a/src/ir/lower/comptime.zig +++ b/src/ir/lower/comptime.zig @@ -420,6 +420,7 @@ fn scanDeclareNames(self: *Lowering, node: *const Node, depth: u32) void { .name = nid, .fields = &.{}, .tag_type = .i64, + .defined = false, // forward placeholder — never-completed `declare` stays rejected } }, 0); // Bind the name as a type alias too: a `Name :: ()` decl // makes `Name` a const_decl author, so a `*Name` self-reference @@ -544,14 +545,16 @@ pub fn runComptimeTypeFunc(self: *Lowering, func_id: FuncId, span: ast.Span) ?Ty /// Post-check a comptime type-construction result (shared by the VM and legacy /// paths). A bare `declare("X")` never completed by a `define(handle, …)` leaves -/// a zero-FIELD nominal slot (an undefined enum); sizing / constructing / emitting -/// it panics at codegen (`verifySizes`: llvm_size != ir_size). Reject it loudly -/// here — a zero-variant enum is never a legitimate result (`defineEnum` rejects -/// an empty variant list too). Returns the type, or null after gating the build. +/// a forward `tagged_union` PLACEHOLDER (`defined == false`); sizing / +/// constructing / emitting it panics at codegen (`verifySizes`: llvm_size != +/// ir_size). Reject it loudly here. An *explicitly* defined empty type (an empty +/// struct / tuple / enum / tagged_union — `defined == true`, possibly 0 fields) +/// is a legitimate result and passes through. Returns the type, or null after +/// gating the build. fn checkComptimeTypeResult(self: *Lowering, tid: TypeId, span: ast.Span) ?TypeId { if (!tid.isBuiltin()) { const info = self.module.types.get(tid); - if (info == .tagged_union and info.tagged_union.fields.len == 0) { + if (info == .tagged_union and !info.tagged_union.defined) { if (self.diagnostics) |d| d.addFmt(.err, span, "type '{s}' is declared but never defined — complete it with define(handle, info)", .{self.module.types.getString(info.tagged_union.name)}); return null; diff --git a/src/ir/types.zig b/src/ir/types.zig index d2b974a2..a78873f7 100644 --- a/src/ir/types.zig +++ b/src/ir/types.zig @@ -148,6 +148,14 @@ pub const TypeInfo = union(enum) { backing_type: ?TypeId = null, // enum struct backing (e.g. { tag: u32; _: u32; payload: [30]u32; }) explicit_tag_values: ?[]const i64 = null, // explicit variant values (e.g., quit :: 0x100) nominal_id: u32 = 0, // stable nominal identity; 0 == structural (legacy) + // True for every real construction (normal unions, error sets, and a + // `register_type`/`define` completion). False ONLY for a `declare(...)` + // forward PLACEHOLDER that has not yet been completed — a 0-field + // tagged_union that is indistinguishable from an explicitly-defined + // empty union by field count alone. `checkComptimeTypeResult` rejects + // `defined == false` (declared but never defined) while accepting a + // legitimately-empty `defined == true` union. + defined: bool = true, }; pub const ArrayInfo = struct { @@ -879,6 +887,12 @@ pub const TypeTable = struct { const fs = self.typeSizeBytes(f.ty); if (fs > max_payload) max_payload = fs; } + // Mirror the LLVM lowering (backend/llvm/types.zig): the payload + // area is laid out as `[max_size x i8]` with a floor of 8 when no + // field carries a payload (all-void / empty union). Without this + // floor an empty/all-void tagged_union sizes to tag_size only, + // diverging from the LLVM type and tripping verifySizes. + if (max_payload == 0) max_payload = 8; const tag_size = self.typeSizeBytes(u.tag_type); const raw = max_payload + tag_size; break :blk (raw + 7) & ~@as(usize, 7);