comptime: empty-member types are valid for all kinds; keep never-defined declare rejected

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.
This commit is contained in:
agra
2026-06-19 21:41:07 +03:00
parent ccba704378
commit 538349611e
9 changed files with 90 additions and 18 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@
empty struct/tuple/enum/tagged_union are all valid

View File

@@ -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();
| ^^^^^^^^^^^^^^

View File

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

View File

@@ -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 :: <ctor>()` 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;

View File

@@ -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);