mem: Steps 5-7 — context-identifier rebind + interp ctx bootstrap

Step 5 — `context` resolves through `current_ctx_ref`. The compile-time
emit of the default GPA into the `context` global is gone; entry points
already bind `current_ctx_ref` to `&__sx_default_context` and every
sx-to-sx call forwards it. `allocViaContext` sources from
`current_ctx_ref` too. `matchContextAllocCall` is kept as a comptime
escape hatch: the ct_module spun up by `evalComptimeString` doesn't get
the full Allocator/CAllocator/Context type registration so the protocol-
dispatch chain wouldn't run in the interp; codegen also wins from the
direct libc malloc/free.

Step 6 — `push Context.{...}` stack-discipline rewrite. Allocates a
fresh `Context` slot, binds `current_ctx_ref` to it for the body's
lexical scope, restores on exit. No global, no walk.

Step 7 — interp parity. `defaultContextValue()` builds the Context
aggregate (CAllocator thunks for alloc/dealloc, null data) on demand.
`interp.call` bootstraps slot_ptr(0) when an entry function with
implicit ctx is called sans args; `materializeCtxArg` dereferences the
caller's slot_ptr into the aggregate at every sx-to-sx call boundary so
the callee's `load(ref_0)` lands on the value; `load` of an aggregate
is a passthrough. `.global_addr` of `__sx_default_context` returns the
aggregate directly so exported entries' first-line `global_addr(...)`
runs cleanly in `#run`.

`ct_lowering` inherits `implicit_ctx_enabled` + `has_implicit_ctx` so
functions lowered into the ct module carry ctx like their main-module
twins.

152/152 example tests pass. Snapshots regen.
This commit is contained in:
agra
2026-05-25 09:10:04 +03:00
parent 92c6b47f12
commit 4bf5908792
14 changed files with 440 additions and 966 deletions

View File

@@ -1014,11 +1014,6 @@ pub const Lowering = struct {
}
}
// Auto-initialize context with default GPA at the start of main()
if (std.mem.eql(u8, name, "main")) {
self.emitDefaultContextInit();
}
// Lower the function body (set target_type to return type for implicit returns)
const saved_target = self.target_type;
self.target_type = if (ret_ty != .void) ret_ty else null;
@@ -1907,6 +1902,18 @@ pub const Lowering = struct {
.enum_tag => |et| break :blk self.builder.constInt(@intCast(et.tag), et.ty),
}
}
// `context` resolves to a load through the lowering's
// current `__sx_ctx` pointer. Every sx function (and
// every `push Context.{...}` body) sets `current_ctx_ref`
// to a `*Context` it owns, so this is one indirection.
if (self.implicit_ctx_enabled and std.mem.eql(u8, id.name, "context")) {
if (self.current_ctx_ref != Ref.none) {
const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
break :blk self.emitError("context", node.span);
};
break :blk self.builder.load(self.current_ctx_ref, ctx_ty);
}
}
// Check globals (#run constants)
if (self.global_names.get(id.name)) |gi| {
break :blk self.builder.emit(.{ .global_get = gi.id }, gi.ty);
@@ -5013,7 +5020,11 @@ pub const Lowering = struct {
}
}
// Pattern-match context.allocator.alloc/dealloc → heap_alloc/heap_free
// Pattern-match context.allocator.alloc/dealloc → heap_alloc/heap_free.
// The comptime interp doesn't register the full Allocator
// protocol in ct_module, so the protocol-dispatch chain it
// would otherwise emit can't run. Codegen also benefits —
// direct libc malloc/free, no thunk indirection.
if (self.matchContextAllocCall(fa, args.items)) |ref| return ref;
// Type constructor call: Sx(f32).user(0.5) — obj is a call that returns a type
@@ -5459,15 +5470,18 @@ pub const Lowering = struct {
/// allocators.sx, so the fallback exists strictly for the bootstrapping
/// edge case.
fn allocViaContext(self: *Lowering, size_ref: Ref, void_ptr_ty: TypeId) Ref {
const ctx_gi = self.global_names.get("context") orelse {
if (!self.implicit_ctx_enabled or self.current_ctx_ref == Ref.none) {
return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty);
}
const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty);
};
const ctx_ty_info = self.module.types.get(ctx_gi.ty);
const ctx_ty_info = self.module.types.get(ctx_ty);
if (ctx_ty_info != .@"struct" or ctx_ty_info.@"struct".fields.len < 1) {
return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty);
}
const allocator_ty = ctx_ty_info.@"struct".fields[0].ty;
const ctx = self.builder.emit(.{ .global_get = ctx_gi.id }, ctx_gi.ty);
const ctx = self.builder.load(self.current_ctx_ref, ctx_ty);
const allocator = self.builder.structGet(ctx, 0, allocator_ty);
// #inline Allocator protocol layout: { ctx, alloc_fn_ptr, dealloc_fn_ptr }.
// field 0 = receiver ctx, field 1 = alloc fn-ptr.
@@ -5512,16 +5526,18 @@ pub const Lowering = struct {
return new_args;
}
/// Pattern-match `context.allocator.alloc(size)` → heap_alloc,
/// `context.allocator.dealloc(ptr)` → heap_free.
/// Pattern-match `context.allocator.alloc(size)` → heap_alloc and
/// `context.allocator.dealloc(ptr)` → heap_free. Required because the
/// comptime interpreter doesn't get a full type/protocol registration
/// of the Allocator chain; the protocol-dispatch lowering would
/// produce IR that the interp can't execute. Codegen wins from this
/// short-circuit too (libc malloc/free direct, no thunk indirection
/// for the trivial default-context case).
fn matchContextAllocCall(self: *Lowering, fa: ast.FieldAccess, call_args: []const Ref) ?Ref {
// fa is the callee field_access: expecting .alloc or .dealloc
if (!std.mem.eql(u8, fa.field, "alloc") and !std.mem.eql(u8, fa.field, "dealloc")) return null;
// fa.object should be `context.allocator` — another field_access
if (fa.object.data != .field_access) return null;
const inner = fa.object.data.field_access;
if (!std.mem.eql(u8, inner.field, "allocator")) return null;
// inner.object should be `context` — an identifier
if (inner.object.data != .identifier) return null;
if (!std.mem.eql(u8, inner.object.data.identifier.name, "context")) return null;
@@ -5530,7 +5546,6 @@ pub const Lowering = struct {
const ptr_void = self.module.types.ptrTo(.void);
return self.builder.emit(.{ .heap_alloc = .{ .operand = call_args[0] } }, ptr_void);
} else {
// dealloc
if (call_args.len < 1) return null;
return self.builder.emit(.{ .heap_free = .{ .operand = call_args[0] } }, .void);
}
@@ -6171,23 +6186,30 @@ pub const Lowering = struct {
}
fn lowerPush(self: *Lowering, ps: *const ast.PushStmt) void {
// push context_expr { body }
// → save = global_get(context), global_set(context, new_val), body, global_set(context, save)
const gi = self.global_names.get("context") orelse {
// No context global — just lower the body without push/pop
// push Context.{...} { body } — allocates a fresh Context on the
// stack frame, rebinds the lowering's `current_ctx_ref` to it for
// the body's lexical scope, then restores. No global, no walk.
if (!self.implicit_ctx_enabled) {
self.lowerBlock(ps.body);
return;
}
const ctx_ty = self.module.types.findByName(self.module.types.internString("Context")) orelse {
self.lowerBlock(ps.body);
return;
};
// Save current context
const save = self.builder.emit(.{ .global_get = gi.id }, gi.ty);
// Lower the new context value
const saved_ctx_ref = self.current_ctx_ref;
defer self.current_ctx_ref = saved_ctx_ref;
const saved_target = self.target_type;
self.target_type = ctx_ty;
const ctx_val = self.lowerExpr(ps.context_expr);
// Store into context global
self.builder.emitVoid(.{ .global_set = .{ .global = gi.id, .value = ctx_val } }, .void);
// Lower the body
self.target_type = saved_target;
const slot = self.builder.alloca(ctx_ty);
self.builder.store(slot, ctx_val);
self.current_ctx_ref = slot;
self.lowerBlock(ps.body);
// Restore saved context
self.builder.emitVoid(.{ .global_set = .{ .global = gi.id, .value = save } }, .void);
}
fn lowerMultiAssign(self: *Lowering, ma: *const ast.MultiAssign) void {
@@ -6419,6 +6441,12 @@ pub const Lowering = struct {
ct_lowering.main_file = null; // no main file filtering
ct_lowering.comptime_param_nodes = self.comptime_param_nodes;
ct_lowering.fn_ast_map = self.fn_ast_map; // share AST map for lazy resolution
// Inherit the implicit-ctx switch: the parent program uses
// Context, so functions lowered into ct_module must carry
// __sx_ctx too (otherwise the inserted code's `context.X`
// reads can't resolve through current_ctx_ref).
ct_lowering.implicit_ctx_enabled = self.implicit_ctx_enabled;
ct_module.has_implicit_ctx = self.implicit_ctx_enabled;
// Lower only the functions reachable from this expression.
// For a call like build_format(fmt), we need build_format's AST.
@@ -10419,52 +10447,6 @@ pub const Lowering = struct {
};
}
/// Auto-initialize the global `context` with a default GPA allocator at the start of main().
/// Emits IR instructions equivalent to:
/// __default_gpa : GPA = .{ alloc_count = 0 };
/// context = Context.{ allocator = GPA.create(@__default_gpa), data = null };
fn emitDefaultContextInit(self: *Lowering) void {
// Look up the context global
const ctx_gi = self.global_names.get("context") orelse return;
const ctx_ty = ctx_gi.ty;
// Look up GPA type
const gpa_ty = self.module.types.findByName(self.module.types.internString("GPA")) orelse return;
// Look up Allocator type
const alloc_ty = self.module.types.findByName(self.module.types.internString("Allocator")) orelse return;
// Get GPA→Allocator thunks
const thunks = self.getOrCreateThunks("Allocator", "GPA");
if (thunks.len < 2) return;
// 1. Stack-allocate GPA with alloc_count = 0
const gpa_slot = self.builder.alloca(gpa_ty);
const zero = self.builder.constInt(0, .s64);
const gpa_val = self.builder.emit(.{ .struct_init = .{
.fields = self.alloc.dupe(Ref, &.{zero}) catch return,
} }, gpa_ty);
self.builder.store(gpa_slot, gpa_val);
// 2. Build Allocator inline protocol value: { ctx: *void, alloc_fn, dealloc_fn }
const void_ptr_ty = self.module.types.ptrTo(.void);
const gpa_ptr = gpa_slot; // alloca already gives us *GPA, all pointers are compatible
const alloc_fn = self.builder.emit(.{ .func_ref = thunks[0] }, void_ptr_ty);
const dealloc_fn = self.builder.emit(.{ .func_ref = thunks[1] }, void_ptr_ty);
const alloc_val = self.builder.emit(.{ .struct_init = .{
.fields = self.alloc.dupe(Ref, &.{ gpa_ptr, alloc_fn, dealloc_fn }) catch return,
} }, alloc_ty);
// 3. Build Context struct: { allocator, data: null }
const null_ptr = self.builder.constNull(void_ptr_ty);
const ctx_val = self.builder.emit(.{ .struct_init = .{
.fields = self.alloc.dupe(Ref, &.{ alloc_val, null_ptr }) catch return,
} }, ctx_ty);
// 4. Store into context global
self.builder.emitVoid(.{ .global_set = .{ .global = ctx_gi.id, .value = ctx_val } }, .void);
}
fn emitModuleConst(self: *Lowering, ci: ModuleConstInfo) Ref {
switch (ci.value.data) {
.int_literal => |lit| {