mem: implicit-context foundation + many compiler fixes

The session-long set of changes that lay the groundwork for the
Jai-literal implicit-Context-parameter refactor. Lots of accumulated
work; the new arrival is the implicit-ctx foundation (steps 1+2 of
the plan in current/CHECKPOINT-MEM.md):

  Step 1 — `CAllocator :: struct {}` stateless allocator in
    library/modules/allocators.sx, delegating directly to
    libc_malloc/libc_free. `ConstantValue` in src/ir/inst.zig gains a
    `func_ref: FuncId` leaf so nested aggregates can carry function
    pointers (the inline Allocator value's fn-ptr fields). Switch
    sites updated in emit_llvm.zig, print.zig, interp.zig.

  Step 2 — `emitDefaultContextGlobal` in src/ir/lower.zig synthesises
    a static `__sx_default_context` global with a nested-aggregate
    init_val pointing at the CAllocator → Allocator thunks. The
    second-pass `initVtableGlobals` in emit_llvm.zig is generalised
    to handle `.aggregate` init_vals (re-emits after func_map is
    populated so func_ref leaves resolve to real symbols).

Also folded in from earlier work this session:

  - Phase 1.1: `xx value` heap-copy in `buildProtocolValue` routes
    through `context.allocator` via the new `allocViaContext` helper.
  - interp.zig: `marshalForeignArg` double-offset bug fixed —
    `heapSlice` already adds `hp.offset` to the slice ptr, so the
    extra `+ hp.offset` was scribbling memcpy/memset into adjacent
    heap state, corrupting `heap.items[0]`. Symptom: `build_format`
    at comptime produced zero bytes, all `print` calls failed.
  - Lazy lowering: `lazyLowerFunction` now declares foreign-body
    functions as extern stubs in the local (comptime) module so
    cross-module foreign calls resolve.
  - Allocator API: all stdlib allocators on one-line `init() -> *T`
    (CAllocator/GPA: libc-backed; Arena/TrackingAllocator: parent-
    backed; BufAlloc: embeds state at head of user buffer).
  - issues 0038 (transitive #import), 0039 (chess + stdlib migration
    fallout), 0040 (generic struct method dot-dispatch), 0041
    (pointer types as type-arg), 0042 (alias name resolution) — all
    fixed; regression tests in examples/.
  - Diagnostic: `emitError` now embeds the lowering's
    `current_source_file` and enclosing function in the literal
    message; SX_TRACE_UNRESOLVED=1 dumps a Zig stack trace at the
    emit site so misattributed spans can't hide where the failure
    is.
  - tools/verify-step.sh (all-platforms gate) and tools/scratch.sh
    (interp/codegen parity tester) added.

Test suite: 152 example tests pass; chess builds + screenshots on
macOS / iOS sim / Android.
This commit is contained in:
agra
2026-05-24 22:59:20 +03:00
parent 0ba41b2980
commit 29784c22a8
63 changed files with 3448 additions and 1207 deletions

View File

@@ -161,6 +161,10 @@ pub const Lowering = struct {
name: []const u8,
param_types: []const TypeId, // excluding self
ret_type: TypeId,
// True when the AST return type was `Self` (encoded here as *void).
// Lets the dispatcher distinguish Self-disguised-as-*void (auto-unbox
// on the caller side) from a literal `-> *void` (return as-is).
ret_is_self: bool = false,
};
/// One impl block for a parameterised protocol (e.g. `impl Into(Block) for Closure() -> void`).
@@ -214,6 +218,12 @@ pub const Lowering = struct {
self.scanDecls(decls);
// Pass 1b: inject compile-time constants (OS, ARCH, POINTER_SIZE) from target config
self.injectComptimeConstants();
// Pass 1c: emit the process-wide default Context global, statically
// initialised to a CAllocator-backed Allocator value. Used by FFI
// wrappers in Step 4 and by the interp's `callWithDefaultContext`
// entry. Only fires when the program imports `std.sx` (so Context +
// Allocator + CAllocator are all registered).
self.emitDefaultContextGlobal();
// Pass 2: lower main (and comptime side-effects)
self.lowerMainAndComptime(decls);
// Pass 3: lower deferred functions (any_to_string etc.) now that all types are registered
@@ -418,10 +428,32 @@ pub const Lowering = struct {
} else if (cd.value.data == .union_decl) {
// Register plain union types in the type table
_ = type_bridge.resolveAstType(cd.value, &self.module.types);
} else if (cd.value.data == .type_expr) {
// Type alias: MyFloat :: f64; → register MyFloat as alias for f64
} else if (cd.value.data == .type_expr or
cd.value.data == .pointer_type_expr or
cd.value.data == .many_pointer_type_expr or
cd.value.data == .array_type_expr or
cd.value.data == .slice_type_expr or
cd.value.data == .optional_type_expr or
cd.value.data == .function_type_expr)
{
// Type alias: MyFloat :: f64; Ptr :: *u8; Cb :: (s32) -> s32;
const target_ty = type_bridge.resolveAstType(cd.value, &self.module.types);
self.type_alias_map.put(cd.name, target_ty) catch {};
} else if (cd.value.data == .identifier) {
// Identifier-RHS alias: MyAlias :: MyInt; WideAlias :: Wide;
// Chase through type_alias_map, then look up named types
// in the table. Forward references resolve lazily because
// the .identifier branch of resolveTypeArg also consults
// type_alias_map at use time.
const rhs_name = cd.value.data.identifier.name;
if (self.type_alias_map.get(rhs_name)) |chained| {
self.type_alias_map.put(cd.name, chained) catch {};
} else {
const name_id = self.module.types.internString(rhs_name);
if (self.module.types.findByName(name_id)) |tid| {
self.type_alias_map.put(cd.name, tid) catch {};
}
}
}
// Handle generic struct instantiation: Vec3 :: Vec(3, f32)
// Parser produces a .call node for these (not parameterized_type_expr)
@@ -723,11 +755,38 @@ pub const Lowering = struct {
// Only restrict C import fn_decls: foreign_expr with no library_ref
if (fd.body.data != .foreign_expr) return true;
if (fd.body.data.foreign_expr.library_ref != null) return true;
// It's a C import fn_decl — check module scope
return self.isNameVisible(fn_name);
}
/// Non-transitive `#import` visibility check for top-level decls.
///
/// `module_scopes[F]` holds ONLY the names authored in file F (plus its
/// namespace aliases). Cross-module visibility is joined here at query
/// time by walking each direct flat-import edge in `import_graph` — a
/// name is visible from F when it's authored in F or in any module F
/// directly `#import`s. Doing the join here (instead of pre-merging in
/// `resolveImports`) lets cyclic imports like std.sx ↔ allocators.sx
/// still resolve, since the cycle's skipped edge is still recorded in
/// `import_graph` and the partner's scope is filled in by the time
/// lowering queries it.
///
/// Falls open when the scoping infrastructure isn't wired (comptime
/// callers, directory imports without main_file, etc.). The caller is
/// responsible for restricting the call to names that ARE known
/// top-level decls; otherwise every local variable would be policed.
fn isNameVisible(self: *Lowering, name: []const u8) bool {
const scopes = self.module_scopes orelse return true;
const source = self.current_source_file orelse return true;
const scope = scopes.get(source) orelse return true;
return scope.contains(fn_name);
const own_scope = scopes.get(source) orelse return true;
if (own_scope.contains(name)) return true;
const graph = self.import_graph orelse return true;
const direct = graph.get(source) orelse return true;
var it = direct.iterator();
while (it.next()) |kv| {
const dep = scopes.get(kv.key_ptr.*) orelse continue;
if (dep.contains(name)) return true;
}
return false;
}
/// Lazily lower a function body on demand. Called when lowerCall can't find
@@ -737,8 +796,21 @@ pub const Lowering = struct {
if (self.lowered_functions.contains(name)) return;
// No AST? (builtins, foreign functions, or imported functions not in this file)
const fd = self.fn_ast_map.get(name) orelse return;
// Check builtin/foreign/generic — these stay as extern stubs
if (fd.body.data == .builtin_expr or fd.body.data == .foreign_expr or fd.body.data == .compiler_expr) return;
// Foreign declarations stay as extern stubs but need to be REGISTERED
// in the current module so callers get a real FuncId. Without this,
// a comptime-lowered function (e.g. `concat` from std.sx pulled into
// a fresh ct_module via `evalComptimeString`) emits `.call` against a
// FuncId that doesn't exist locally; the interp can't find the
// foreign target and silently no-ops instead of dispatching to libc.
if (fd.body.data == .foreign_expr) {
if (self.resolveFuncByName(name) == null) {
self.declareFunction(fd, name);
self.lowered_functions.put(name, {}) catch {};
}
return;
}
// Builtins / #compiler bodies stay as compiler-handled — no extern stub needed.
if (fd.body.data == .builtin_expr or fd.body.data == .compiler_expr) return;
if (fd.type_params.len > 0) return; // generics handled by monomorphization (Step 3.13)
// Defer functions with type-category matches until all types are registered.
@@ -1715,12 +1787,29 @@ pub const Lowering = struct {
}
// Check module-level value constants (e.g. AF_INET :s32: 2)
if (self.module_const_map.get(id.name)) |ci| {
if (!self.isNameVisible(id.name)) {
if (self.diagnostics) |d|
d.addFmt(.err, node.span, "'{s}' is not visible; #import the module that declares it", .{id.name});
break :blk self.emitError(id.name, node.span);
}
break :blk self.emitModuleConst(ci);
}
// Check if it's a function name — produce function pointer reference
// Resolve mangled name for block-local functions
const eff_fn_name = if (self.scope) |scope| scope.lookupFn(id.name) orelse id.name else id.name;
if (self.fn_ast_map.contains(eff_fn_name)) {
// Visibility check only for user-typed bare names (id.name
// == eff_fn_name) without a UFCS alias. Mangled local-
// scope names and UFCS rewrites are compiler indirections
// and stay exempt.
if (std.mem.eql(u8, eff_fn_name, id.name) and
self.ufcs_alias_map.get(id.name) == null and
!self.isNameVisible(eff_fn_name))
{
if (self.diagnostics) |d|
d.addFmt(.err, node.span, "'{s}' is not visible; #import the module that declares it", .{eff_fn_name});
break :blk self.emitError(eff_fn_name, node.span);
}
// Type-as-value: if target is Any (Type variable), produce a type name string
if (self.target_type == .any) {
const fd = self.fn_ast_map.get(eff_fn_name).?;
@@ -4423,6 +4512,19 @@ pub const Lowering = struct {
d.addFmt(.err, c.callee.span, "C function '{s}' not visible; add #import for the module that declares it", .{eff_name});
return Ref.none;
}
// Non-transitive `#import` visibility check. Apply only when the
// user-typed name resolved as-is to a top-level fn — local-scope
// mangling (eff_name != id_name) and UFCS alias rewriting are
// compiler indirections and stay exempt.
if (std.mem.eql(u8, eff_name, id_name) and
self.ufcs_alias_map.get(id_name) == null and
self.fn_ast_map.contains(eff_name) and
!self.isNameVisible(eff_name))
{
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is not visible; #import the module that declares it", .{eff_name});
return Ref.none;
}
if (self.fn_ast_map.get(eff_name)) |fd| {
if (self.current_match_tags) |tags| {
if (tags.len > 0 and self.hasCastWithRuntimeType(c)) {
@@ -4608,19 +4710,8 @@ pub const Lowering = struct {
}
// Check builtins first (these are handled natively by interpreter and emitter)
if (resolveBuiltin(id.name)) |bid| {
// free(protocol_value) → extract ctx (field 0) and free it
if (bid == .free and args.items.len == 1) {
const arg_ty = self.builder.getRefType(args.items[0]);
if (self.getProtocolInfo(arg_ty) != null) {
const void_ptr_ty = self.module.types.ptrTo(.void);
const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = args.items[0], .field_index = 0 } }, void_ptr_ty);
return self.builder.emit(.{ .heap_free = .{ .operand = ctx_ref } }, .void);
}
}
const ret_ty: TypeId = switch (bid) {
.malloc => .s64, // pointer
.size_of => .s64,
.memcpy, .memset => .s64,
.size_of, .align_of => .s64,
.sqrt, .sin, .cos, .floor => blk: {
// Math builtins: return type matches argument type ($T -> T)
if (c.args.len > 0) {
@@ -5041,6 +5132,53 @@ pub const Lowering = struct {
}
}
// Generic method on a non-template struct: `obj.method($T, ...)`
// or inferred form `obj.method(val)` where val's type pins $T.
if (self.fn_ast_map.get(qualified)) |gen_fd| {
if (gen_fd.type_params.len > 0 and gen_fd.body.data != .compiler_expr) {
// Effective AST args: prepend receiver so positions
// line up with fd.params (which has self at index 0).
var eff_args = std.ArrayList(*const Node).empty;
defer eff_args.deinit(self.alloc);
eff_args.append(self.alloc, effective_obj_node) catch unreachable;
for (c.args) |a| eff_args.append(self.alloc, a) catch unreachable;
var gbindings = self.buildTypeBindings(gen_fd, eff_args.items);
defer gbindings.deinit();
const gmangled = self.mangleGenericName(qualified, gen_fd, &gbindings);
if (!self.lowered_functions.contains(gmangled)) {
self.monomorphizeFunction(gen_fd, gmangled, &gbindings);
}
if (self.resolveFuncByName(gmangled)) |gfid| {
const gfunc = &self.module.functions.items[@intFromEnum(gfid)];
const gret_ty = gfunc.ret;
const gparams = gfunc.params;
// Strip type-decl slots from method_args. method_args[0] is the
// receiver (corresponds to fd.params[0] = self, never a type decl).
// Walk fd.params[1..], advance arg_idx through method_args[1..].
var gvalue_args = std.ArrayList(Ref).empty;
defer gvalue_args.deinit(self.alloc);
gvalue_args.append(self.alloc, method_args.items[0]) catch unreachable;
const types_explicit = method_args.items.len == gen_fd.params.len;
var arg_idx: usize = 1;
for (gen_fd.params[1..]) |p| {
if (isTypeParamDecl(&p, gen_fd.type_params)) {
if (types_explicit) arg_idx += 1;
continue;
}
if (arg_idx < method_args.items.len) {
gvalue_args.append(self.alloc, method_args.items[arg_idx]) catch unreachable;
}
arg_idx += 1;
}
self.fixupMethodReceiver(&gvalue_args, gfunc, effective_obj_node, obj_ty);
self.coerceCallArgs(gvalue_args.items, gparams);
return self.builder.call(gfid, gvalue_args.items, gret_ty);
}
}
}
// Try non-generic qualified method
if (self.fn_ast_map.get(qualified)) |fd| {
if (!self.lowered_functions.contains(qualified)) {
@@ -5134,6 +5272,49 @@ pub const Lowering = struct {
}
}
/// Emit `context.allocator.alloc(size)` dispatch — used by internal
/// compiler-driven heap copies (e.g. the `xx value` protocol-erasure
/// path in `buildProtocolValue`). Routes through whatever allocator is
/// currently installed in `context`, so a surrounding
/// `push Context.{ allocator = my_alloc, ... }` actually backs every
/// allocation including the ones the compiler inserts.
///
/// Falls back to `.heap_alloc` (libc malloc) only when the `context`
/// global hasn't been registered (programs that don't `#import
/// "modules/std.sx"`). All standard sx code imports std.sx via
/// 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 {
return self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty);
};
const ctx_ty_info = self.module.types.get(ctx_gi.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 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.
const alloc_ctx = self.builder.structGet(allocator, 0, void_ptr_ty);
const fn_ptr = self.builder.structGet(allocator, 1, void_ptr_ty);
const args = self.alloc.dupe(Ref, &.{ alloc_ctx, size_ref }) catch unreachable;
return self.builder.emit(.{ .call_indirect = .{
.callee = fn_ptr,
.args = args,
} }, void_ptr_ty);
}
/// Emit a call to a foreign-declared function looked up by name.
/// Used for the compiler-internal byte-copy in the protocol-erasure
/// heap path and the closure env-copy path, both of which need
/// libc `memcpy` after the `#builtin` form was dropped.
fn callForeign(self: *Lowering, name: []const u8, args: []const Ref, ret_ty: TypeId) Ref {
const fid = self.resolveFuncByName(name) orelse @panic("foreign symbol missing — std.sx not imported?");
return self.builder.call(fid, args, ret_ty);
}
/// Pattern-match `context.allocator.alloc(size)` → heap_alloc,
/// `context.allocator.dealloc(ptr)` → heap_free.
fn matchContextAllocCall(self: *Lowering, fa: ast.FieldAccess, call_args: []const Ref) ?Ref {
@@ -5177,11 +5358,8 @@ pub const Lowering = struct {
.{ "cos", inst_mod.BuiltinId.cos },
.{ "floor", inst_mod.BuiltinId.floor },
.{ "size_of", inst_mod.BuiltinId.size_of },
.{ "align_of", inst_mod.BuiltinId.align_of },
.{ "cast", inst_mod.BuiltinId.cast },
.{ "malloc", inst_mod.BuiltinId.malloc },
.{ "free", inst_mod.BuiltinId.free },
.{ "memcpy", inst_mod.BuiltinId.memcpy },
.{ "memset", inst_mod.BuiltinId.memset },
};
inline for (builtins) |entry| {
if (std.mem.eql(u8, name, entry[0])) return entry[1];
@@ -5349,11 +5527,7 @@ pub const Lowering = struct {
const env_byte_size_inner = self.computeEnvSize(capture_list);
const env_size_val = self.builder.constInt(@intCast(env_byte_size_inner), .s64);
// memcpy(local_alloca, env_param, size)
const cp_args = self.alloc.dupe(Ref, &.{ env_local, env_param_ref, env_size_val }) catch unreachable;
_ = self.builder.emit(.{ .call_builtin = .{
.builtin = inst_mod.BuiltinId.memcpy,
.args = cp_args,
} }, self.module.types.ptrTo(.void));
_ = self.callForeign("memcpy", &.{ env_local, env_param_ref, env_size_val }, self.module.types.ptrTo(.void));
for (capture_list, 0..) |cap, i| {
// GEP into env struct to get field pointer
@@ -5440,11 +5614,7 @@ pub const Lowering = struct {
const ptr_void = self.module.types.ptrTo(.void);
const env_heap = self.builder.emit(.{ .heap_alloc = .{ .operand = env_size } }, ptr_void);
// memcpy(heap, stack_alloca, size)
const args = self.alloc.dupe(Ref, &.{ env_heap, env_local, env_size }) catch unreachable;
_ = self.builder.emit(.{ .call_builtin = .{
.builtin = inst_mod.BuiltinId.memcpy,
.args = args,
} }, ptr_void);
_ = self.callForeign("memcpy", &.{ env_heap, env_local, env_size }, ptr_void);
return self.builder.closureCreate(func_id, env_heap, closure_ty);
} else {
@@ -6369,31 +6539,26 @@ pub const Lowering = struct {
// ── Generic monomorphization ──────────────────────────────────
/// Lower a call to a generic function by monomorphizing it with inferred type arguments.
fn lowerGenericCall(self: *Lowering, fd: *const ast.FnDecl, base_name: []const u8, call_node: *const ast.Call, lowered_args: []Ref) Ref {
// Infer type param bindings from call arguments
/// Build `tp.name -> TypeId` bindings for a generic call.
/// `args_ast` must be parallel to `fd.params`; for dot-calls the caller
/// prepends the receiver's AST node so positions align with `fd.params[0] = self`.
/// Caller owns the returned map and must call `.deinit()`.
fn buildTypeBindings(
self: *Lowering,
fd: *const ast.FnDecl,
args_ast: []const *const Node,
) std.StringHashMap(TypeId) {
var bindings = std.StringHashMap(TypeId).init(self.alloc);
defer bindings.deinit();
// Determine if type args are passed explicitly:
// If call_node.args.len == fd.params.len, the caller passed type args explicitly
// (e.g., are_equal(Point, p1, p2)). Otherwise, types are inferred from value args
// (e.g., are_equal(p1, p2)).
const types_passed_explicitly = call_node.args.len == fd.params.len;
const types_passed_explicitly = args_ast.len == fd.params.len;
for (fd.type_params) |tp| {
var found = false;
// Strategy 1: Direct type param declaration ($T: Type)
// The param whose name matches the type param IS the declaration.
// The call arg at that position is a type expression — resolve it directly.
// Only applies when type args are passed explicitly in the call.
// Strategy 1: explicit — the param whose name matches `tp.name` IS
// the `$T: Type` declaration; the arg at that position is a type expression.
if (types_passed_explicitly) {
for (fd.params, 0..) |param, pi| {
if (std.mem.eql(u8, param.name, tp.name)) {
// This param IS the type param declaration
if (pi < call_node.args.len) {
const ty = self.resolveTypeArg(call_node.args[pi]);
if (pi < args_ast.len and type_bridge.isTypeShapedAstNode(args_ast[pi], &self.module.types)) {
const ty = self.resolveTypeArg(args_ast[pi]);
bindings.put(tp.name, ty) catch {};
found = true;
}
@@ -6402,11 +6567,8 @@ pub const Lowering = struct {
}
}
if (found) continue;
// Strategy 2: Infer from params that USE the type param (e.g., a: $T, b: T, items: []$T)
// Check ALL params whose type matches the type param name, pick widest type.
// When types are inferred (not explicit), use a separate arg index that
// skips type param declarations to correctly map params to call args.
// Strategy 2: infer from value params that USE the type param
// (e.g. a: $T, b: T, items: []$T). Pick widest type across matches.
var inferred_ty: ?TypeId = null;
var s2_arg_idx: usize = 0;
for (fd.params) |param| {
@@ -6420,8 +6582,8 @@ pub const Lowering = struct {
}
const matched = self.matchTypeParam(param.type_expr, tp.name);
if (matched) {
if (s2_arg_idx < call_node.args.len) {
const arg_ty = self.inferExprType(call_node.args[s2_arg_idx]);
if (s2_arg_idx < args_ast.len) {
const arg_ty = self.inferExprType(args_ast[s2_arg_idx]);
const extracted = self.extractTypeParam(param.type_expr, arg_ty, tp.name);
if (extracted) |ety| {
if (inferred_ty) |prev| {
@@ -6441,8 +6603,17 @@ pub const Lowering = struct {
bindings.put(tp.name, ty) catch {};
}
}
return bindings;
}
// Build mangled name: "func_name__Type1_Type2"
/// Mangle a generic call site into "base__Type1_Type2".
/// Returns a heap-allocated string owned by self.alloc.
fn mangleGenericName(
self: *Lowering,
base_name: []const u8,
fd: *const ast.FnDecl,
bindings: *const std.StringHashMap(TypeId),
) []const u8 {
var mangled_buf: [256]u8 = undefined;
var mangled_len: usize = 0;
for (base_name) |ch| {
@@ -6452,14 +6623,12 @@ pub const Lowering = struct {
}
}
for (fd.type_params) |tp| {
// Append separator
for ("__") |ch| {
if (mangled_len < mangled_buf.len) {
mangled_buf[mangled_len] = ch;
mangled_len += 1;
}
}
// Append type name
const ty = bindings.get(tp.name) orelse .s64;
const type_name_str = self.mangleTypeName(ty);
for (type_name_str) |ch| {
@@ -6469,27 +6638,31 @@ pub const Lowering = struct {
}
}
}
const mangled_name = mangled_buf[0..mangled_len];
return self.alloc.dupe(u8, mangled_buf[0..mangled_len]) catch base_name;
}
/// Lower a call to a generic function by monomorphizing it with inferred type arguments.
fn lowerGenericCall(self: *Lowering, fd: *const ast.FnDecl, base_name: []const u8, call_node: *const ast.Call, lowered_args: []Ref) Ref {
var bindings = self.buildTypeBindings(fd, call_node.args);
defer bindings.deinit();
const types_passed_explicitly = call_node.args.len == fd.params.len;
const mangled_name = self.mangleGenericName(base_name, fd, &bindings);
// Check cache
if (!self.lowered_functions.contains(mangled_name)) {
// Monomorphize: create a new function with the mangled name and lower with type bindings
self.monomorphizeFunction(fd, mangled_name, &bindings);
}
// Resolve the monomorphized function and call it (stripping type args)
if (self.resolveFuncByName(mangled_name)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
// Build value-only args (skip type param declaration args)
// Use separate index for lowered_args since type params don't consume call args
var value_args = std.ArrayList(Ref).empty;
defer value_args.deinit(self.alloc);
var arg_idx: usize = 0;
for (fd.params) |p| {
if (isTypeParamDecl(&p, fd.type_params)) {
// Only skip in lowered_args if types were passed explicitly in the call
if (types_passed_explicitly) arg_idx += 1;
continue;
}
@@ -6894,6 +7067,11 @@ pub const Lowering = struct {
const size: i64 = @intCast(self.typeSizeBytes(ty));
return self.builder.constInt(size, .s64);
}
if (std.mem.eql(u8, name, "align_of")) {
const ty = self.resolveTypeArg(c.args[0]);
const a: i64 = @intCast(self.module.types.typeAlignBytes(ty));
return self.builder.constInt(a, .s64);
}
if (std.mem.eql(u8, name, "field_count")) {
// field_count(T) → const_int(N)
const ty = self.resolveTypeArg(c.args[0]);
@@ -7019,9 +7197,13 @@ pub const Lowering = struct {
if (self.type_bindings) |tb| {
if (tb.get(id.name)) |ty| return ty;
}
// Try as a named type by name (resolveAstType doesn't handle .identifier)
if (self.type_alias_map.get(id.name)) |alias_ty| return alias_ty;
const name_id = self.module.types.internString(id.name);
return self.module.types.findByName(name_id) orelse .s64;
if (self.module.types.findByName(name_id)) |t| return t;
if (self.diagnostics) |diags| {
diags.addFmt(.err, node.span, "unresolved type: '{s}'", .{id.name});
}
return .void;
},
.type_expr => |te| {
if (self.type_alias_map.get(te.name)) |alias_ty| return alias_ty;
@@ -7031,6 +7213,14 @@ pub const Lowering = struct {
// Handle type constructor calls: size_of(Sx(f32)), size_of(Complex(u32))
return self.resolveTypeCallWithBindings(&cl);
},
.pointer_type_expr,
.many_pointer_type_expr,
.array_type_expr,
.slice_type_expr,
.optional_type_expr,
.function_type_expr,
.tuple_literal,
=> return type_bridge.resolveAstType(node, &self.module.types),
else => return .s64,
}
}
@@ -7498,6 +7688,41 @@ pub const Lowering = struct {
// skipping the first param (self) since it's prepended later.
if (c.callee.data == .field_access) {
const fa = c.callee.data.field_access;
// Namespace/static call: `Type.method(args)` where `Type` is a type
// identifier (not a value in scope). Args correspond to ALL params
// — no self prepend — so target_type for arg lowering must include
// the leading param. Skipping it would lose the protocol context
// for `xx ptr` inline-cast args.
if (fa.object.data == .identifier) {
const obj_name = fa.object.data.identifier.name;
const is_value = blk: {
if (self.scope) |scope| {
if (scope.lookup(obj_name) != null) break :blk true;
}
if (self.global_names.contains(obj_name)) break :blk true;
break :blk false;
};
if (!is_value) {
const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ obj_name, fa.field }) catch return &.{};
if (self.resolveFuncByName(qualified)) |fid| {
const func = &self.module.functions.items[@intFromEnum(fid)];
var types_list = std.ArrayList(TypeId).empty;
for (func.params) |p| {
types_list.append(self.alloc, p.ty) catch unreachable;
}
return types_list.items;
}
if (self.fn_ast_map.get(qualified)) |fd| {
var types_list = std.ArrayList(TypeId).empty;
for (fd.params) |p| {
types_list.append(self.alloc, self.resolveParamType(&p)) catch unreachable;
}
return types_list.items;
}
}
}
const obj_ty = self.inferExprType(fa.object);
// Protocol-typed receiver: look up the method on the protocol decl. The
// protocol's ProtocolMethodInfo.param_types already excludes self.
@@ -8511,9 +8736,11 @@ pub const Lowering = struct {
};
ptypes.append(self.alloc, pty) catch unreachable;
}
var ret_is_self = false;
const ret = if (method.return_type) |rt| blk: {
if (rt.data == .type_expr) {
if (std.mem.eql(u8, rt.data.type_expr.name, "Self")) {
ret_is_self = true;
break :blk void_ptr_ty;
}
if (self.type_alias_map.get(rt.data.type_expr.name)) |aliased| {
@@ -8526,6 +8753,7 @@ pub const Lowering = struct {
.name = method.name,
.param_types = self.alloc.dupe(TypeId, ptypes.items) catch unreachable,
.ret_type = ret,
.ret_is_self = ret_is_self,
}) catch unreachable;
}
self.protocol_decl_map.put(pd.name, .{
@@ -8799,6 +9027,54 @@ pub const Lowering = struct {
return owned;
}
/// Emit the process-wide default Context as an LLVM static constant.
///
/// @__sx_default_context = internal constant %Context {
/// %Allocator { ptr null,
/// ptr @__thunk_CAllocator_Allocator_alloc,
/// ptr @__thunk_CAllocator_Allocator_dealloc },
/// ptr null
/// }
///
/// Used by FFI inbound wrappers (Step 4) and the interp's default-
/// context call entry (Step 7). Only emitted when the program imports
/// `std.sx` — without that, Context / Allocator / CAllocator aren't
/// registered and the global has no purpose.
fn emitDefaultContextGlobal(self: *Lowering) void {
const tbl = &self.module.types;
const ctx_name_id = tbl.internString("Context");
const ctx_ty = tbl.findByName(ctx_name_id) orelse return;
if (tbl.findByName(tbl.internString("Allocator")) == null) return;
if (tbl.findByName(tbl.internString("CAllocator")) == null) return;
// Force the CAllocator → Allocator thunks to exist so we can
// reference them by FuncId in the static initializer.
const thunks = self.getOrCreateThunks("Allocator", "CAllocator");
if (thunks.len < 2) return;
// Inline Allocator value: { ctx: *void, alloc_fn: *void, dealloc_fn: *void }
// CAllocator is stateless, so ctx is null.
const alloc_fields = self.alloc.alloc(inst_mod.ConstantValue, 3) catch return;
alloc_fields[0] = .null_val;
alloc_fields[1] = .{ .func_ref = thunks[0] };
alloc_fields[2] = .{ .func_ref = thunks[1] };
// Context value: { allocator: Allocator, data: *void }
const ctx_fields = self.alloc.alloc(inst_mod.ConstantValue, 2) catch return;
ctx_fields[0] = .{ .aggregate = alloc_fields };
ctx_fields[1] = .null_val;
const global_name = "__sx_default_context";
const global_name_id = tbl.internString(global_name);
const gid = self.module.addGlobal(.{
.name = global_name_id,
.ty = ctx_ty,
.init_val = .{ .aggregate = ctx_fields },
.is_const = true,
});
self.global_names.put(global_name, .{ .id = gid, .ty = ctx_ty }) catch {};
}
/// Create a thunk function: __thunk_ConcreteType_Protocol_method(ctx: *void, args...) -> ret
/// The thunk calls ConcreteType.method(ctx, args...).
fn createProtocolThunk(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, method: ProtocolMethodInfo) FuncId {
@@ -8925,12 +9201,8 @@ pub const Lowering = struct {
if (heap_copy) {
const concrete_size = self.module.types.typeSizeBytes(concrete_ty);
const size_ref = self.builder.constInt(@intCast(concrete_size), .s64);
const heap_ptr = self.builder.emit(.{ .heap_alloc = .{ .operand = size_ref } }, void_ptr_ty);
const memcpy_args = self.alloc.dupe(Ref, &.{ heap_ptr, concrete_ptr, size_ref }) catch unreachable;
_ = self.builder.emit(.{ .call_builtin = .{
.builtin = inst_mod.BuiltinId.memcpy,
.args = memcpy_args,
} }, void_ptr_ty);
const heap_ptr = self.allocViaContext(size_ref, void_ptr_ty);
_ = self.callForeign("memcpy", &.{ heap_ptr, concrete_ptr, size_ref }, void_ptr_ty);
ctx_ptr = heap_ptr;
}
@@ -9055,12 +9327,11 @@ pub const Lowering = struct {
const owned = self.alloc.dupe(Ref, call_args.items) catch unreachable;
const raw_result = self.builder.emit(.{ .call_indirect = .{ .callee = fn_ptr, .args = owned } }, mi.ret_type);
// If protocol method returns *void (Self) and the caller expects a value type,
// unbox: load the concrete value from the returned pointer. Real pointer
// returns (declared `-> *T` for non-Self T) are NOT auto-loaded — the
// pointee may be a single byte and reading `sizeof(target)` past it
// segfaults. Self is encoded as `*void`, so test against that exact type.
if (mi.ret_type == void_ptr) {
// If the protocol method was declared `-> Self` (encoded here as *void)
// and the caller expects a value type, unbox: load the concrete value
// from the returned pointer. A literal `-> *void` return is NOT
// auto-loaded — it's a real pointer whose pointee size we don't know.
if (mi.ret_is_self) {
if (self.target_type) |target| {
const target_info = self.module.types.get(target);
if (target_info != .pointer) {
@@ -9149,7 +9420,7 @@ pub const Lowering = struct {
}
break :blk TypeId.f64;
},
.size_of, .malloc => .s64,
.size_of, .align_of => .s64,
.cast => if (c.args.len > 0) self.resolveTypeArg(c.args[0]) else .s64,
else => .s64,
};
@@ -9376,15 +9647,17 @@ pub const Lowering = struct {
defer tmp_bindings.deinit();
for (fd.type_params) |tp| {
// Strategy 1: direct type param decl ($T: Type) — param.name == tp.name
// Strategy 1: direct type param decl ($T: Type) — param.name == tp.name.
// Only fires when the caller actually supplied a type expression at
// that position; otherwise fall through to value-based inference.
var found = false;
for (fd.params, 0..) |param, pi| {
if (std.mem.eql(u8, param.name, tp.name)) {
if (pi < c.args.len) {
if (pi < c.args.len and type_bridge.isTypeShapedAstNode(c.args[pi], &self.module.types)) {
const ty = self.resolveTypeArg(c.args[pi]);
tmp_bindings.put(tp.name, ty) catch {};
found = true;
}
found = true;
break;
}
}
@@ -9482,6 +9755,21 @@ pub const Lowering = struct {
return self.buildProtocolErasure(operand, operand_node, src_ty, dst_ty);
}
// Protocol → pointer: recover the typed ctx pointer (field 0).
// The protocol value is `{ ctx, fn1, fn2, ... }` (inline) or
// `{ ctx, vtable_ptr }` — either way, ctx lives at field 0.
if (self.getProtocolInfo(src_ty)) |_| {
if (!dst_ty.isBuiltin()) {
const dst_info = self.module.types.get(dst_ty);
if (dst_info == .pointer) {
const void_ptr_ty = self.module.types.ptrTo(.void);
const ctx_ref = self.builder.emit(.{ .struct_get = .{ .base = operand, .field_index = 0 } }, void_ptr_ty);
if (dst_ty == void_ptr_ty) return ctx_ref;
return self.builder.emit(.{ .bitcast = .{ .operand = ctx_ref, .from = void_ptr_ty, .to = dst_ty } }, dst_ty);
}
}
}
const result = self.coerceToType(operand, src_ty, dst_ty);
// User-space fallback via `impl Into(Target) for Source`. Only fires
@@ -9900,7 +10188,26 @@ pub const Lowering = struct {
fn emitError(self: *Lowering, name: []const u8, span: ?ast.Span) Ref {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "unresolved: '{s}'", .{name});
// The literal message carries the lowering's `current_source_file`
// and enclosing function name. The diagnostic renderer's
// `source_file` -> `file:line:col` prefix can drift when a span is
// offset into one source but the diagnostic falls back to another
// (e.g. synthetic AST nodes inserted from `#insert` take their
// span from the call site, not from the string being inserted).
// Embedding the file + function in the message means a
// misattributed span can never hide WHERE the lookup actually
// failed. Setting SX_TRACE_UNRESOLVED=1 also dumps a Zig stack
// trace at the emit site to surface the calling lowering path.
const sf = self.current_source_file orelse "<unknown>";
const fn_name: []const u8 = if (self.builder.func) |fid|
self.module.types.getString(self.module.functions.items[@intFromEnum(fid)].name)
else
"<top-level>";
if (std.c.getenv("SX_TRACE_UNRESOLVED") != null) {
std.debug.print("\n== unresolved '{s}' (in {s} fn {s}) ==\n", .{ name, sf, fn_name });
std.debug.dumpCurrentStackTrace(.{ .first_address = @returnAddress() });
}
diags.addFmt(.err, span, "unresolved '{s}' (in {s} fn {s})", .{ name, sf, fn_name });
}
return self.emitPlaceholder(name);
}