compiler-API: welded structs by reflection + memory-order validation
Replace the explored byte-layout-override engine (offset-ordered LLVM structs /
weld plans / byte-blobs — all unnecessary) with a much simpler design: a welded
`struct abi(.zig) extern compiler { … }` is a bodied header declaring its fields
in the bound compiler type's MEMORY order. The compiler reflects the real Zig
type (field names via @typeInfo, offsets via @offsetOf, size via @sizeOf —
nothing hand-maintained) and validates the header matches, with loud diagnostics.
On pass it is an ordinary struct whose natural layout already equals the Zig
layout — no reorder, no padding, no index/remap tables, no special LLVM path — so
@ptrCast'ing it to the compiler's own type and dereferencing is byte-identical.
When types.zig shifts, the header stops matching and the developer gets a specific
message to fix it.
- compiler_lib.zig: weldStruct reflects field names and bakes bound_types fields
in ascending-offset (memory) order; deleted computeWeldPlan/WeldPlan/WeldElement.
- nominal.zig validateWeldedStruct: precise diagnostics — field-not-found,
wrong-field-order (+ expected memory order), type-layout (size) mismatch,
total-size mismatch.
- Examples: 0627 (StructInfo in memory order, byte-identical, usable),
1186 (source-order StructInfo -> wrong-field-order diagnostic); 1183 refreshed.
- Design doc + checkpoint updated.
This commit is contained in:
@@ -104,66 +104,32 @@ test "compiler_lib: validateStructLayout flags each kind of drift" {
|
||||
}
|
||||
}
|
||||
|
||||
// Lock: `Field` (natural two-u32 layout) has the trivial weld plan — two field
|
||||
// elements in declaration order, no padding, identity remap.
|
||||
test "compiler_lib: weld plan for Field is the identity (no reorder, no pad)" {
|
||||
const alloc = std.testing.allocator;
|
||||
const bt = compiler_lib.findType("Field").?;
|
||||
var plan = try compiler_lib.computeWeldPlan(alloc, bt.fields, bt.size);
|
||||
defer plan.deinit(alloc);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 8), plan.total_size);
|
||||
try std.testing.expectEqual(@as(usize, 2), plan.elements.len);
|
||||
// Identity remap.
|
||||
try std.testing.expectEqual(@as(usize, 0), plan.sx_to_llvm[0]);
|
||||
try std.testing.expectEqual(@as(usize, 1), plan.sx_to_llvm[1]);
|
||||
// Both elements are real fields at 0 and 4.
|
||||
try std.testing.expectEqual(@as(?usize, 0), plan.elements[0].sx_field);
|
||||
try std.testing.expectEqual(@as(usize, 0), plan.elements[0].offset);
|
||||
try std.testing.expectEqual(@as(?usize, 1), plan.elements[1].sx_field);
|
||||
try std.testing.expectEqual(@as(usize, 4), plan.elements[1].offset);
|
||||
}
|
||||
|
||||
// Lock: `StructInfo` is the first NON-natural layout — Zig reorders it to
|
||||
// (fields@0, name@16, nominal_id@20, is_protocol@24) with a 7-byte alignment
|
||||
// tail. The plan must reproduce exactly that order + the sx→element remap, so the
|
||||
// LLVM type built from it is byte-identical to the Zig type. sx declaration order
|
||||
// is (name, fields, is_protocol, nominal_id) = sx indices 0,1,2,3.
|
||||
test "compiler_lib: weld plan for StructInfo reorders + pads to the Zig layout" {
|
||||
const alloc = std.testing.allocator;
|
||||
// Lock: `StructInfo` is reflected in MEMORY order — Zig reorders it from source
|
||||
// order (name, fields, is_protocol, nominal_id) to (fields@0, name@16,
|
||||
// nominal_id@20, is_protocol@24). The registry must present the fields in that
|
||||
// memory order, since an sx welded header must declare them so to be
|
||||
// byte-identical.
|
||||
test "compiler_lib: StructInfo is reflected in Zig memory order" {
|
||||
const StructInfoZig = types.TypeInfo.StructInfo;
|
||||
const bt = compiler_lib.findType("StructInfo").?;
|
||||
var plan = try compiler_lib.computeWeldPlan(alloc, bt.fields, bt.size);
|
||||
defer plan.deinit(alloc);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 32), plan.total_size);
|
||||
try std.testing.expectEqual(@sizeOf(StructInfoZig), bt.size);
|
||||
try std.testing.expectEqual(@as(usize, 4), bt.fields.len);
|
||||
|
||||
// Elements in ascending offset order: fields, name, nominal_id, is_protocol,
|
||||
// then a trailing 7-byte pad (25 → 32).
|
||||
try std.testing.expectEqual(@as(usize, 5), plan.elements.len);
|
||||
// Memory order: fields, name, nominal_id, is_protocol.
|
||||
try std.testing.expectEqualStrings("fields", bt.fields[0].name);
|
||||
try std.testing.expectEqual(@offsetOf(StructInfoZig, "fields"), bt.fields[0].offset);
|
||||
try std.testing.expectEqualStrings("name", bt.fields[1].name);
|
||||
try std.testing.expectEqual(@offsetOf(StructInfoZig, "name"), bt.fields[1].offset);
|
||||
try std.testing.expectEqualStrings("nominal_id", bt.fields[2].name);
|
||||
try std.testing.expectEqual(@offsetOf(StructInfoZig, "nominal_id"), bt.fields[2].offset);
|
||||
try std.testing.expectEqualStrings("is_protocol", bt.fields[3].name);
|
||||
try std.testing.expectEqual(@offsetOf(StructInfoZig, "is_protocol"), bt.fields[3].offset);
|
||||
|
||||
// elem 0: fields (sx index 1) @ 0, size 16
|
||||
try std.testing.expectEqual(@as(?usize, 1), plan.elements[0].sx_field);
|
||||
try std.testing.expectEqual(@as(usize, 0), plan.elements[0].offset);
|
||||
try std.testing.expectEqual(@as(usize, 16), plan.elements[0].size);
|
||||
// elem 1: name (sx index 0) @ 16, size 4
|
||||
try std.testing.expectEqual(@as(?usize, 0), plan.elements[1].sx_field);
|
||||
try std.testing.expectEqual(@as(usize, 16), plan.elements[1].offset);
|
||||
// elem 2: nominal_id (sx index 3) @ 20, size 4
|
||||
try std.testing.expectEqual(@as(?usize, 3), plan.elements[2].sx_field);
|
||||
try std.testing.expectEqual(@as(usize, 20), plan.elements[2].offset);
|
||||
// elem 3: is_protocol (sx index 2) @ 24, size 1
|
||||
try std.testing.expectEqual(@as(?usize, 2), plan.elements[3].sx_field);
|
||||
try std.testing.expectEqual(@as(usize, 24), plan.elements[3].offset);
|
||||
// elem 4: trailing pad @ 25, size 7
|
||||
try std.testing.expectEqual(@as(?usize, null), plan.elements[4].sx_field);
|
||||
try std.testing.expectEqual(@as(usize, 25), plan.elements[4].offset);
|
||||
try std.testing.expectEqual(@as(usize, 7), plan.elements[4].size);
|
||||
|
||||
// sx → element remap: name→1, fields→0, is_protocol→3, nominal_id→2.
|
||||
try std.testing.expectEqual(@as(usize, 1), plan.sx_to_llvm[0]);
|
||||
try std.testing.expectEqual(@as(usize, 0), plan.sx_to_llvm[1]);
|
||||
try std.testing.expectEqual(@as(usize, 3), plan.sx_to_llvm[2]);
|
||||
try std.testing.expectEqual(@as(usize, 2), plan.sx_to_llvm[3]);
|
||||
// Offsets are strictly ascending (memory order).
|
||||
try std.testing.expect(bt.fields[0].offset < bt.fields[1].offset);
|
||||
try std.testing.expect(bt.fields[1].offset < bt.fields[2].offset);
|
||||
try std.testing.expect(bt.fields[2].offset < bt.fields[3].offset);
|
||||
}
|
||||
|
||||
// Lock: the welded-function export list resolves the round-trip readers and
|
||||
|
||||
@@ -49,26 +49,31 @@ pub const BoundType = struct {
|
||||
const FieldZig = types.TypeInfo.StructInfo.Field; // { name: StringId, ty: TypeId } — two u32s
|
||||
const StructInfoZig = types.TypeInfo.StructInfo; // { name, fields: []Field, is_protocol, nominal_id } — Zig-reordered
|
||||
|
||||
/// Bake a `BoundType` from a real Zig struct type `T`. Field offsets/sizes come
|
||||
/// from `@offsetOf`/`@sizeOf` on `T`; `sx_field_names` supplies the sx-visible
|
||||
/// names positionally (must match `T`'s field order and count — a mismatch is a
|
||||
/// compile error, never a silent truncation).
|
||||
fn weldStruct(
|
||||
comptime sx_name: []const u8,
|
||||
comptime T: type,
|
||||
comptime sx_field_names: []const []const u8,
|
||||
) BoundType {
|
||||
/// Bake a `BoundType` by REFLECTING the real Zig struct type `T` — field names
|
||||
/// from `@typeInfo`, offsets from `@offsetOf`, sizes from `@sizeOf`. Nothing is
|
||||
/// maintained by hand: a `types.zig` change re-bakes on the next compiler build.
|
||||
/// Fields are returned in ascending-OFFSET (memory) order, which is the order an
|
||||
/// sx welded header must declare them in to be byte-identical (Zig may reorder a
|
||||
/// struct's fields from source order). The sx-visible field name IS the Zig
|
||||
/// field identifier.
|
||||
fn weldStruct(comptime sx_name: []const u8, comptime T: type) BoundType {
|
||||
const zig_fields = @typeInfo(T).@"struct".fields;
|
||||
if (zig_fields.len != sx_field_names.len)
|
||||
@compileError("compiler-lib weld '" ++ sx_name ++ "': sx field count != Zig field count");
|
||||
comptime var layouts: [zig_fields.len]FieldLayout = undefined;
|
||||
inline for (zig_fields, 0..) |zf, i| {
|
||||
layouts[i] = .{
|
||||
.name = sx_field_names[i],
|
||||
.name = zf.name,
|
||||
.offset = @offsetOf(T, zf.name),
|
||||
.size = @sizeOf(zf.type),
|
||||
};
|
||||
}
|
||||
// Sort into memory order so the sx header is checked against the layout the
|
||||
// compiler actually uses (declaration order != memory order under Zig's
|
||||
// auto-layout).
|
||||
comptime std.sort.insertion(FieldLayout, &layouts, {}, struct {
|
||||
fn lt(_: void, a: FieldLayout, b: FieldLayout) bool {
|
||||
return a.offset < b.offset;
|
||||
}
|
||||
}.lt);
|
||||
const frozen = layouts;
|
||||
return .{
|
||||
.sx_name = sx_name,
|
||||
@@ -78,14 +83,13 @@ fn weldStruct(
|
||||
};
|
||||
}
|
||||
|
||||
/// The welded-type export list. `Field` (two u32s, natural layout) proved the
|
||||
/// weld in Phase 1; `StructInfo` (Phase 2) is the first NON-natural layout —
|
||||
/// Zig reorders its fields (`fields`@0, `name`@16, `nominal_id`@20,
|
||||
/// `is_protocol`@24), so it exercises the offset-override engine. `EnumInfo` /
|
||||
/// `TaggedUnionInfo` / `TupleInfo` join later.
|
||||
/// The welded-type export list. Each entry reflects a real internal Zig type;
|
||||
/// the sx header that binds it must mirror these fields IN THIS (memory) ORDER.
|
||||
/// `Field` (two u32s) is naturally ordered; `StructInfo` is Zig-reordered
|
||||
/// (`fields`@0, `name`@16, `nominal_id`@20, `is_protocol`@24).
|
||||
pub const bound_types = [_]BoundType{
|
||||
weldStruct("Field", FieldZig, &.{ "name", "ty" }),
|
||||
weldStruct("StructInfo", StructInfoZig, &.{ "name", "fields", "is_protocol", "nominal_id" }),
|
||||
weldStruct("Field", FieldZig),
|
||||
weldStruct("StructInfo", StructInfoZig),
|
||||
};
|
||||
|
||||
/// Look up a welded type by its sx name. Returns null when the name is not on
|
||||
@@ -150,91 +154,6 @@ pub fn validateStructLayout(
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Weld plan (byte-layout override) ────────────────────────────────────────
|
||||
//
|
||||
// A welded struct must be laid out byte-identically to the bound Zig type, whose
|
||||
// fields Zig may REORDER (and pad). The sx struct's natural layout generally
|
||||
// won't match — so the compiler imposes the Zig layout: it builds the struct's
|
||||
// LLVM type as the fields in ascending-OFFSET order, with explicit padding
|
||||
// elements filling the gaps, and remaps each sx field index to its LLVM element
|
||||
// index. `computeWeldPlan` is that pure layout math; the LLVM type builder + the
|
||||
// struct-GEP / field-access sites consume the plan (later sub-steps), and the
|
||||
// interp serializes comptime struct Values through the same offsets.
|
||||
|
||||
/// One element of a welded struct's LLVM layout: either a real field (carrying
|
||||
/// its sx field index) or a padding gap. Always in ascending `offset` order.
|
||||
pub const WeldElement = struct {
|
||||
/// The sx field index this element holds, or null for a padding gap.
|
||||
sx_field: ?usize,
|
||||
/// Byte offset of this element within the struct (the bound Zig offset).
|
||||
offset: usize,
|
||||
/// Byte width of this element (the field's size, or the gap width).
|
||||
size: usize,
|
||||
};
|
||||
|
||||
/// The byte-layout plan for a welded struct: its LLVM elements in offset order
|
||||
/// (fields + padding) and the sx-field → LLVM-element-index remap. Owns its
|
||||
/// slices — `deinit` with the same allocator passed to `computeWeldPlan`.
|
||||
pub const WeldPlan = struct {
|
||||
elements: []const WeldElement,
|
||||
/// `sx_to_llvm[i]` is the index into `elements` of sx field `i`.
|
||||
sx_to_llvm: []const usize,
|
||||
total_size: usize,
|
||||
|
||||
pub fn deinit(self: *WeldPlan, alloc: std.mem.Allocator) void {
|
||||
alloc.free(self.elements);
|
||||
alloc.free(self.sx_to_llvm);
|
||||
}
|
||||
};
|
||||
|
||||
/// Compute the byte-layout plan for a struct whose fields carry their bound Zig
|
||||
/// offsets (`fields[i].offset`/`.size`, e.g. from a `BoundType`). `total_size` is
|
||||
/// the bound Zig `@sizeOf`. The result lists LLVM elements in ascending-offset
|
||||
/// order — real fields interleaved with padding gaps — plus the sx-field →
|
||||
/// element-index remap that struct-GEP uses. Pure; allocates the result slices.
|
||||
pub fn computeWeldPlan(
|
||||
alloc: std.mem.Allocator,
|
||||
fields: []const FieldLayout,
|
||||
total_size: usize,
|
||||
) !WeldPlan {
|
||||
// Order the sx field indices by ascending byte offset (stable).
|
||||
const order = try alloc.alloc(usize, fields.len);
|
||||
defer alloc.free(order);
|
||||
for (order, 0..) |*o, i| o.* = i;
|
||||
std.sort.insertion(usize, order, fields, struct {
|
||||
fn lessThan(fs: []const FieldLayout, a: usize, b: usize) bool {
|
||||
return fs[a].offset < fs[b].offset;
|
||||
}
|
||||
}.lessThan);
|
||||
|
||||
var elements = std.ArrayList(WeldElement).empty;
|
||||
errdefer elements.deinit(alloc);
|
||||
const sx_to_llvm = try alloc.alloc(usize, fields.len);
|
||||
errdefer alloc.free(sx_to_llvm);
|
||||
|
||||
var cursor: usize = 0;
|
||||
for (order) |sx_i| {
|
||||
const f = fields[sx_i];
|
||||
// Fill any gap before this field with a padding element.
|
||||
if (f.offset > cursor) {
|
||||
try elements.append(alloc, .{ .sx_field = null, .offset = cursor, .size = f.offset - cursor });
|
||||
}
|
||||
sx_to_llvm[sx_i] = elements.items.len;
|
||||
try elements.append(alloc, .{ .sx_field = sx_i, .offset = f.offset, .size = f.size });
|
||||
cursor = f.offset + f.size;
|
||||
}
|
||||
// Trailing padding up to the bound total size (alignment tail).
|
||||
if (total_size > cursor) {
|
||||
try elements.append(alloc, .{ .sx_field = null, .offset = cursor, .size = total_size - cursor });
|
||||
}
|
||||
|
||||
return .{
|
||||
.elements = try elements.toOwnedSlice(alloc),
|
||||
.sx_to_llvm = sx_to_llvm,
|
||||
.total_size = total_size,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Functions (comptime-only, host-call bridged) ────────────────────────────
|
||||
|
||||
/// A welded `compiler` function: dispatched under the comptime interpreter to its
|
||||
|
||||
@@ -753,14 +753,40 @@ fn validateWeldedStruct(self: *Lowering, sd: *const ast.StructDecl, tid: TypeId,
|
||||
const total = table.typeSizeBytes(tid);
|
||||
|
||||
const mismatch = compiler_lib.validateStructLayout(bt, sx_fields.items, total) orelse return;
|
||||
// The compiler type's fields, in the memory order an sx header must mirror —
|
||||
// included in the order/count diagnostics so the fix is obvious.
|
||||
const order = weldedFieldOrderStr(self.alloc, bt);
|
||||
defer if (order.len > 0) self.alloc.free(order);
|
||||
switch (mismatch) {
|
||||
.field_count => |m| diags.addFmt(.err, span, "welded type '{s}' has {d} field(s) in the compiler library but the declaration has {d}", .{ sd.name, m.expected, m.got }),
|
||||
.field_name => |m| diags.addFmt(.err, span, "welded type '{s}' field {d} is named '{s}' in the compiler library, not '{s}'", .{ sd.name, m.index, m.expected, m.got }),
|
||||
.field_size => |m| diags.addFmt(.err, span, "welded type '{s}' field '{s}' is {d} byte(s) in the compiler library but {d} as declared", .{ sd.name, m.name, m.expected, m.got }),
|
||||
.total_size => |m| diags.addFmt(.err, span, "welded type '{s}' is {d} byte(s) in the compiler library but {d} as declared (padding/alignment mismatch)", .{ sd.name, m.expected, m.got }),
|
||||
.field_count => |m| diags.addFmt(.err, span, "welded type '{s}': the compiler type has {d} field(s) but the declaration has {d} — declare them in memory order: {s}", .{ sd.name, m.expected, m.got, order }),
|
||||
.field_name => |m| {
|
||||
// Distinguish "this name isn't a field at all" from "right field set,
|
||||
// wrong order".
|
||||
const exists = blk: {
|
||||
for (bt.fields) |bf| if (std.mem.eql(u8, bf.name, m.got)) break :blk true;
|
||||
break :blk false;
|
||||
};
|
||||
if (exists)
|
||||
diags.addFmt(.err, span, "welded type '{s}': wrong field order at position {d} — found '{s}', the compiler type has '{s}' here (memory order: {s})", .{ sd.name, m.index, m.got, m.expected, order })
|
||||
else
|
||||
diags.addFmt(.err, span, "welded type '{s}': field '{s}' is not a field of the compiler type (its fields, in memory order: {s})", .{ sd.name, m.got, order });
|
||||
},
|
||||
.field_size => |m| diags.addFmt(.err, span, "welded type '{s}': type layout mismatch — field '{s}' is {d} byte(s) in the compiler type but {d} as declared", .{ sd.name, m.name, m.expected, m.got }),
|
||||
.total_size => |m| diags.addFmt(.err, span, "welded type '{s}': layout mismatch — the compiler type is {d} byte(s) but the declaration is {d} (alignment/padding)", .{ sd.name, m.expected, m.got }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound type's field names in memory order, `, `-joined, for diagnostics.
|
||||
/// Returns an owned string; empty (no free needed) on allocation failure.
|
||||
fn weldedFieldOrderStr(alloc: std.mem.Allocator, bt: *const compiler_lib.BoundType) []const u8 {
|
||||
var buf = std.ArrayList(u8).empty;
|
||||
for (bt.fields, 0..) |bf, i| {
|
||||
if (i > 0) buf.appendSlice(alloc, ", ") catch return "";
|
||||
buf.appendSlice(alloc, bf.name) catch return "";
|
||||
}
|
||||
return buf.toOwnedSlice(alloc) catch "";
|
||||
}
|
||||
|
||||
/// Register a top-level ENUM decl under a per-decl nominal identity (E6a) —
|
||||
/// the enum twin of `registerStructDecl`. A GENUINE same-name shadow already
|
||||
/// reserved its DISTINCT slot up-front in `scanDecls` (the first at id 0, the
|
||||
|
||||
Reference in New Issue
Block a user