green(metatype): declare(name) + self-reference (recursive enums via *Name)

declare now takes the type's NAME — `declare(name) -> Type` — because the
compiler needs it at compile time to register the forward type, which is
what makes self-reference resolve. EnumInfo drops `name` (it lives on
declare now); define completes the handle's body in place (the slot is
already named).

Self-reference mechanism (evalComptimeType): before lowering a comptime
type expression, preregisterForwardTypes scans it (and a called ctor fn's
body) for `declare("Name")` calls and registers each as an empty forward
nominal type AND binds it as a type alias. The alias is essential: a
`Name :: ctor()` decl makes `Name` a const_decl author, so a `*Name`
self-reference resolves through the forward-ALIAS path
(type_aliases_by_source), which a bare findByName registration doesn't
satisfy. With both in place `*Name` resolves to the forward slot at lower
time; the interp's declare returns that same slot; define fills it.

  List :: make_list();
  make_list :: () -> Type {
      h := declare("List");
      return define(h, .enum(.{ variants = .[
          EnumVariant.{ name = "cons", payload = *List },
          EnumVariant.{ name = "nil",  payload = void } ] }));
  }

Verified: cons/nil construct + match (direct and through the pointer),
multi-node list traversal via a recursive `count(*List)`. meta.sx
RecvResult/TryResult + examples 0614/0615/0617 updated to declare(name);
full suite green (673).
This commit is contained in:
agra
2026-06-16 22:02:48 +03:00
parent 12e2ff7ef4
commit 7a9db03bcc
6 changed files with 104 additions and 54 deletions

View File

@@ -1,5 +1,5 @@
// Comptime type construction: mint a NEW nominal enum from a `TypeInfo` value
// via the `define(declare(), info)` primitives, then construct one of its
// via the `define(declare("E"), info)` primitives, then construct one of its
// variants and match on it — exercising that a programmatically-built enum
// (with NO backing AST decl) flows through enum codegen unmodified (layout /
// construct / match), byte-identical to a hand-written enum.
@@ -9,7 +9,7 @@
#import "modules/std.sx";
#import "modules/std/meta.sx";
E :: define(declare(), .enum(.{ name = "E", variants = .[
E :: define(declare("E"), .enum(.{ variants = .[
EnumVariant.{ name = "value", payload = i64 },
EnumVariant.{ name = "closed", payload = void },
] }));

View File

@@ -1,5 +1,5 @@
// Comptime type construction — identity: a type-fn that builds a type with
// `define(declare(), ...)` must memoize by the instantiation's mangled name, so
// `define(declare("Box"), ...)` must memoize by the instantiation's mangled name, so
// `Box(i64)` resolved at two INDEPENDENT sites (here: a return type and a
// parameter type) is ONE `TypeId`. A value built at one site is therefore
// assignable / matchable at the other — nominal identity. If the minted result
@@ -9,7 +9,7 @@
#import "modules/std/meta.sx";
Box :: ($T: Type) -> Type {
return define(declare(), .enum(.{ name = "Box", variants = .[
return define(declare("Box"), .enum(.{ variants = .[
EnumVariant.{ name = "some", payload = T },
EnumVariant.{ name = "none", payload = void },
] }));

View File

@@ -16,11 +16,10 @@ EnumVariant :: struct {
payload: Type;
}
// The shape of an enum/tagged-union being reflected or constructed. `name` is
// the type's name — it travels WITH the shape (so `define` can name the slot and
// `type_info` round-trips it); the compiler derives nothing from a binding LHS.
// The shape of an enum/tagged-union being reflected or constructed. The type's
// NAME is supplied to `declare(name)`, not here — `declare` needs it at compile
// time to register the forward type so the body can reference itself (`*Name`).
EnumInfo :: struct {
name: string;
variants: []EnumVariant;
}
@@ -33,20 +32,19 @@ TypeInfo :: enum {
}
// The compiler's ONLY type-construction primitives (comptime-only #builtins):
// declare() — mint a NEW empty (undefined) nominal type, returned
// as a `Type` handle. Using it before its `define` is a
// loud error; references to it (`*Self`) are fine.
// define(handle, info) — fill a declared handle's body from a `TypeInfo`
// (which carries the type's name), and RETURN the
// handle so the one-shot form chains:
// T :: define(declare(), info);
// The recursive / mutually-recursive form keeps them apart so the handle can be
// referenced inside its own definition:
// List :: declare();
// define(List, .enum(.{ name = "List", variants = .[
// EnumVariant.{ name = "cons", payload = *List },
// EnumVariant.{ name = "nil", payload = void } ] }));
declare :: () -> Type #builtin;
// declare(name) — mint a NEW empty (undefined) nominal type NAMED
// `name`, returned as a `Type` handle. The compiler
// registers the forward type at compile time, so the
// body of `define` can reference it BY NAME — that's how
// self-reference works (`payload = *List` resolves to the
// forward `List`). Using the type before its `define` is
// a loud error; a pointer to it is fine.
// define(handle, info) — fill a declared handle's body from a `TypeInfo`, and
// RETURN the handle so the one-shot form chains:
// List :: define(declare("List"), .enum(.{ variants = .[
// EnumVariant.{ name = "cons", payload = *List },
// EnumVariant.{ name = "nil", payload = void } ] }));
declare :: (name: string) -> Type #builtin;
define :: (handle: Type, info: TypeInfo) -> Type #builtin;
type_info :: ($T: Type) -> TypeInfo #builtin;
field_type :: ($T: Type, idx: i64) -> Type #builtin;
@@ -61,7 +59,7 @@ field_type :: ($T: Type, idx: i64) -> Type #builtin;
// A blocking recv: a value, or the channel was closed (drained).
RecvResult :: ($T: Type) -> Type {
return define(declare(), .enum(.{ name = "RecvResult", variants = .[
return define(declare("RecvResult"), .enum(.{ variants = .[
EnumVariant.{ name = "value", payload = T },
EnumVariant.{ name = "closed", payload = void },
] }));
@@ -70,7 +68,7 @@ RecvResult :: ($T: Type) -> Type {
// A non-blocking try-recv: a value, currently empty, or closed — three states
// a bool can't express.
TryResult :: ($T: Type) -> Type {
return define(declare(), .enum(.{ name = "TryResult", variants = .[
return define(declare("TryResult"), .enum(.{ variants = .[
EnumVariant.{ name = "value", payload = T },
EnumVariant.{ name = "empty", payload = void },
EnumVariant.{ name = "closed", payload = void },

View File

@@ -189,10 +189,6 @@ pub const Interpreter = struct {
/// that may run `declare`/`define`. Null elsewhere (unit tests, emit-time
/// `#run`) → those builtins bail loudly.
mint: ?*types.TypeTable = null,
/// Monotonic suffix for `declare()`'s anonymous slot names, so two
/// undefined slots alive at once don't collide in `findByName` before
/// `define` names them (or a type-fn renames them to the mangled name).
declare_counter: u32 = 0,
// Heap: dynamically allocated memory blocks
heap: std.ArrayList([]u8),
@@ -1980,14 +1976,14 @@ pub const Interpreter = struct {
.declare => {
const tbl = self.mint orelse
return bailDetail("comptime declare(): no type-mint target (declare/define are comptime-only — reached at runtime/emit?)");
// Mint an EMPTY (undefined) tagged_union slot under a fresh
// anonymous name. The binding site (`E :: …` / type-fn) renames
// it to the real name afterwards. An empty `fields` is the
// "declared, not yet defined" state — `define` fills it.
var buf: [40]u8 = undefined;
const nm = std.fmt.bufPrint(&buf, "__reified_{d}", .{self.declare_counter}) catch "__reified";
self.declare_counter += 1;
if (bi.args.len != 1) return bailDetail("comptime declare(name): needs the name argument");
const nm = frame.getRef(bi.args[0]).asString(self) orelse
return bailDetail("comptime declare(): name is not a string");
const name_id = tbl.internString(nm);
// Lowering already registered this named forward slot (so a
// `*Name` self-reference in the body resolved); return THAT slot
// so `define` completes the same one. Mint it if somehow absent.
if (tbl.findByName(name_id)) |existing| return .{ .value = .{ .type_tag = existing } };
const info: types.TypeInfo = .{ .tagged_union = .{
.name = name_id,
.fields = &.{},
@@ -2027,11 +2023,10 @@ pub const Interpreter = struct {
.aggregate => |f| f,
else => return bailDetail("comptime define(): `.enum` payload is not an EnumInfo struct value"),
};
// EnumInfo = `{ name: string, variants: []EnumVariant }`. The name
// travels with the shape — `define` names the slot from it.
if (einfo_fields.len != 2) return bailDetail("comptime define(): EnumInfo must have `name` and `variants`");
const name = einfo_fields[0].asString(self) orelse return bailDetail("comptime define(): EnumInfo `name` is not a string");
const elems = decodeVariantElements(einfo_fields[1]) orelse
// EnumInfo = `{ variants: []EnumVariant }`. The name was given to
// `declare` (it's already the slot's name) — `define` only fills the body.
if (einfo_fields.len != 1) return bailDetail("comptime define(): EnumInfo must have a `variants` field");
const elems = decodeVariantElements(einfo_fields[0]) orelse
return bailDetail("comptime define(): `variants` is not a slice/array of EnumVariant");
if (elems.len == 0) return bailDetail("comptime define(): enum has no variants");
@@ -2047,22 +2042,21 @@ pub const Interpreter = struct {
fields.append(self.alloc, .{ .name = tbl.internString(vname), .ty = payload_tid }) catch return error.CannotEvalComptime;
}
// Complete the declared slot: NAME it from the EnumInfo (the name travels
// with the shape) and fill the body. The name changes the intern key
// (declare minted an anonymous `__reified_N`), so re-key via
// `replaceKeyedInfo`. The nominal id is preserved.
// Complete the declared slot IN PLACE: it already has its name + nominal
// id (from `declare`); fill the body. Name/id unchanged → the intern key
// is stable, so `updatePreservingKey`.
const cur = tbl.get(handle);
if (cur != .tagged_union) return bailDetail("comptime define(): handle is not a declare()'d enum slot");
const full: types.TypeInfo = .{ .tagged_union = .{
.name = tbl.internString(name),
.name = cur.tagged_union.name,
.fields = fields.items,
.tag_type = .i64,
.backing_type = null,
.explicit_tag_values = null,
.nominal_id = cur.tagged_union.nominal_id,
} };
tbl.replaceKeyedInfo(handle, full);
// Return the handle so the one-shot form chains: `T :: define(declare(), info)`.
tbl.updatePreservingKey(handle, full);
// Return the handle so the one-shot form chains: `T :: define(declare("T"), info)`.
return .{ .value = .{ .type_tag = handle } };
}
};

View File

@@ -1677,15 +1677,20 @@ pub fn tryLowerReflectionCall(self: *Lowering, name: []const u8, c: *const ast.C
if (self.reflectionTypeArgGuard(name, c)) |sentinel| return sentinel;
if (std.mem.eql(u8, name, "declare")) {
// Comptime type-construction primitive: mint an empty nominal slot.
// Comptime-only — emitted as a builtin_call the interp executes against
// its `mint` table; never reaches codegen (its sx callers are only ever
// comptime-evaluated).
if (c.args.len != 0) {
if (self.diagnostics) |d| d.addFmt(.err, c.callee.span, "declare() takes no arguments", .{});
// Comptime type-construction primitive: mint an empty nominal slot NAMED
// by its (compile-time string) argument. Comptime-only — emitted as a
// builtin_call the interp executes against its `mint` table; never
// reaches codegen (its sx callers are only ever comptime-evaluated).
if (c.args.len != 1) {
if (self.diagnostics) |d| d.addFmt(.err, c.callee.span, "declare(name) takes one string argument", .{});
return Ref.none;
}
return self.builder.callBuiltin(.declare, &.{}, .any);
// The named forward type is pre-registered by `evalComptimeType`'s
// `preregisterForwardTypes` before this body lowers (so a `*Name`
// self-reference resolves); the interp's `declare` returns that slot.
const name_ref = self.lowerExpr(c.args[0]);
const args_owned = self.alloc.dupe(Ref, &.{name_ref}) catch return Ref.none;
return self.builder.callBuiltin(.declare, args_owned, .any);
}
if (std.mem.eql(u8, name, "define")) {
// Comptime type-construction primitive: complete a declare()'d slot

View File

@@ -393,7 +393,60 @@ pub fn lowerInsertExprValue(self: *Lowering, expr: *const Node) Ref {
/// name (the type-fn mangled-name path) renames afterwards via
/// `renameNominalType`. Returns null (caller poisons) if evaluation didn't yield
/// a Type.
/// Register an empty forward nominal type named by each `declare("Name")` call
/// reachable from `expr` (and, if `expr` is a call to a known fn, that fn's
/// body). Runs before the comptime expression lowers so a `*Name` self-reference
/// resolves to this forward slot. Idempotent (skips an already-registered name).
fn preregisterForwardTypes(self: *Lowering, expr: *const Node) void {
scanDeclareNames(self, expr, 0);
if (expr.data == .call and expr.data.call.callee.data == .identifier) {
if (self.program_index.fn_ast_map.get(expr.data.call.callee.data.identifier.name)) |fd| {
scanDeclareNames(self, fd.body, 0);
}
}
}
fn scanDeclareNames(self: *Lowering, node: *const Node, depth: u32) void {
if (depth > 64) return;
switch (node.data) {
.call => |c| {
if (c.callee.data == .identifier and
std.mem.eql(u8, c.callee.data.identifier.name, "declare") and
c.args.len == 1 and c.args[0].data == .string_literal)
{
const nm = c.args[0].data.string_literal.raw;
const nid = self.module.types.internString(nm);
const tid = self.module.types.findByName(nid) orelse self.module.types.internNominal(.{ .tagged_union = .{
.name = nid,
.fields = &.{},
.tag_type = .i64,
} }, 0);
// Bind the name as a type alias too: a `Name :: <ctor>()` decl
// makes `Name` a const_decl author, so a `*Name` self-reference
// resolves through the forward-ALIAS path — which checks
// `type_aliases_by_source`, not `findByName`. Without this the
// alias path returns a pending empty-struct stub instead.
self.putTypeAlias(self.current_source_file, nm, tid);
}
for (c.args) |a| scanDeclareNames(self, a, depth + 1);
},
.block => |b| for (b.stmts) |s| scanDeclareNames(self, s, depth + 1),
.return_stmt => |r| if (r.value) |v| scanDeclareNames(self, v, depth + 1),
.var_decl => |v| if (v.value) |val| scanDeclareNames(self, val, depth + 1),
.const_decl => |cd| scanDeclareNames(self, cd.value, depth + 1),
.struct_literal => |sl| for (sl.field_inits) |fi| scanDeclareNames(self, fi.value, depth + 1),
.array_literal => |al| for (al.elements) |e| scanDeclareNames(self, e, depth + 1),
else => {},
}
}
pub fn evalComptimeType(self: *Lowering, expr: *const Node) ?TypeId {
// Pre-register every `declare("Name")` forward type BEFORE lowering, so a
// self-referential `*Name` payload resolves (the name is a known forward
// type when the body lowers). Done up-front rather than at declare's
// lowering because a `*Name` can lower before its `declare` within the same
// body. The interp's `declare` returns this same slot; `define` completes it.
preregisterForwardTypes(self, expr);
const func_id = self.createComptimeFunction("__ctype", expr, .any);
var interp = interp_mod.Interpreter.init(self.module, self.alloc);