Three holdover free functions from the pre-M3 era were each
just two or three lines that forwarded to a global. With M3
finished, every call site is one #objc_class method body, so
the wrapper indirection earns nothing — inline them.
Deleted:
uikit_window_getter → body of SxSceneDelegate.window
uikit_window_setter → body of SxSceneDelegate.setWindow
uikit_did_finish_launching → body of
SxAppDelegate.application_didFinishLaunchingWithOptions
The bigger helpers (uikit_keyboard_will_change_frame,
uikit_scene_will_connect, uikit_gl_view_tick/_layout, the four
uikit_gl_view_touches_*) stay — their bodies are 30-80 lines
each, so wrapping them in a small forwarding method body inside
#objc_class is the cleaner factoring.
Chess on iOS-sim: board renders, full game state intact. 183
example tests + zig build test green.
Three slices in one commit since they're tightly coupled (the
M3.5 deletion only makes sense after M3.3 and M3.4):
M3.3 — SxGLView migrated to declarative '#objc_class("SxGLView")':
- '#extends UIView' for the view-hierarchy + responder chain.
- 'layerClass :: *void = objc_getClass("CAEAGLLayer".ptr);' uses
the M2.1(a) class-level constant form. Registered on the
metaclass; UIView's +layerClass override dispatches here so
EAGL gets the right backing layer.
- Six instance methods (sxTick, layoutSubviews, four touch
selectors) forward to existing legacy IMP free functions.
M3.4 — SxMetalView migrated, same shape as SxGLView; differs only
in the 'layerClass' constant returning CAMetalLayer instead of
CAEAGLLayer. The five shared IMPs (sxTick/layoutSubviews/4 touch
handlers) reach the same free functions — they already branch on
plat.gpu_mode for GL-specific renderbuffer code.
M3.5 — uikit_register_classes() and the two helper registration
functions are deleted outright. Every sx-defined Obj-C class in
this module now goes through the compiler's M1.2 / M2.1(a)
synthesis path at module init. The call site inside
UIKitPlatform.init is gone too — just a comment marking the
migration point.
Chess on iOS-sim: board renders, scene-delegate connection still
fires, GL/Metal layer setup intact, touch dispatch routes through
the synthesized IMP trampolines. 183 example tests + zig build
test green.
End of M3. The platform layer's Obj-C-runtime wiring is fully
declarative.
Remaining: M4 (autoreleasepool + ARC ops), M5 (closure↔block),
M6 (auto-import + production hardening). M1.1.b (Class(T)
parameterization + instancetype) is still deferred — none of
the migrated uikit code needed it.
Migrates SxSceneDelegate from the hand-rolled
objc_allocateClassPair + class_addMethod + class_addProtocol
sequence to the declarative form:
SxSceneDelegate :: #objc_class("SxSceneDelegate") {
#extends UIResponder;
#implements UISceneDelegate;
#implements UIWindowSceneDelegate;
scene_willConnectToSession_options :: (self, scene, session, options) { ... }
window :: (self) -> *void { ... }
setWindow :: (self, w) { ... }
}
emit_llvm now honors '#implements' in the class-pair init
constructor — for each #implements ProtocolAlias on the cache
entry's AST, emit before objc_registerClassPair:
proto = objc_getProtocol("ProtocolName")
class_addProtocol(cls, proto)
iOS checks 'class_conformsToProtocol' when instantiating scene
delegates; without the conformance the runtime silently rejects
the class and a default scene with no delegate gets created
instead. The protocol-getter returns null on dead-strip /
runtime mismatch (rare but possible) — the runtime treats
class_addProtocol(cls, null) as a no-op, so no explicit null
check needed.
Method bodies forward to the existing legacy free IMP functions
(uikit_scene_will_connect, uikit_window_getter,
uikit_window_setter) so we don't have to inline the scene-
connect setup logic (~80 lines).
uikit_register_classes is now tiny — just the two remaining
view-class helpers (M3.3 SxGLView + M3.4 SxMetalView). M3.5
deletes the function entirely once those land.
Chess on iOS-sim: board renders, scene delegate fires, touch
events route correctly. 183 example tests + zig build test
green.
Two coupled changes that unblock the uikit_register_classes
migration:
1) M1.2 A.3 — body's 'self' is the Obj-C id (opaque), NOT the
state struct. Matches Apple's ObjC semantics where 'self' IS
the object. Cocoa idiom 'xx self → id' works at runtime calls
(addObserver:, etc.); previously the trampoline replaced
'self' with the state-struct pointer, breaking any runtime
call that expected an id.
'*Self' substitution in resolveTypeWithBindings now points at
foreignClassStructType(fcd) — the opaque class stub — instead
of objcDefinedStateStructType(fcd).
'self.field' access on a sx-defined class instance field is
rewritten by lowerFieldAccess to go through the __sx_state
ivar:
state = object_getIvar(self, load(__<Cls>_state_ivar))
val = struct_gep(state, field_idx) → load
Both read (lowerFieldAccess) and write (lowerAssignment) take
this path. Compound ops (+=, -=, etc.) are supported via
storeOrCompound. The lookup is filtered: skip property fields
(those still go through the M2.2 msgSend getter/setter
dispatch) and foreign classes (no state).
New helpers in lower.zig:
- lookupObjcDefinedStateFieldOnPointer — match check.
- lowerObjcDefinedStateForObj — emit the object_getIvar +
ivar-global-load idiom (shared between read + write paths).
- lowerObjcDefinedStateFieldRead — the load path.
Also moved the @llvm.global_ctors registration out of the
sx-defined class-pair init constructor — global_ctors fires
DURING dyld's framework load, before UIKit registers its Obj-C
classes. objc_getClass("UIResponder") returned null, super
was null, objc_registerClassPair crashed. main's entry block
is post-framework-load but pre-user-code — exactly the right
window. New helper injectCtorIntoMain.
2) M3.1 — SxAppDelegate migrated to declarative #objc_class.
uikit_register_classes' hand-rolled objc_allocateClassPair +
class_addMethod for SxAppDelegate is gone; the compiler
synthesises the class at module init. The method bodies
forward to the existing legacy IMP free functions
(uikit_did_finish_launching, uikit_keyboard_will_change_frame)
so we don't have to inline 70+ lines of keyboard-frame logic
right now.
Also adds UIResponder foreign-class declaration and chains
UIView / UITextField to it via #extends UIResponder so the
methods that previously lived on UITextField directly
(becomeFirstResponder etc.) move to their proper home.
Chess on iOS-sim: board renders, full state intact. 183 example
tests + zig build test green.
When 'obj.method()' is called on a foreign-class pointer and the
method isn't declared on the receiver's class, the compiler walks
the '#extends' chain to find an ancestor that declared it.
Property lookup (M2.2) flows through the same chain walker.
ParentX :: #foreign #objc_class("...") { foo :: ... }
ChildX :: #foreign #objc_class("...") { #extends ParentX; }
child.foo() // now resolves — was 'no method foo on ChildX'
Two new helpers in lower.zig:
- findForeignMethodInChain(fcd, name) walks the cache via
fcd.members[i].extends → foreign_class_map[parent] → ...
Depth-capped at 16 to break accidental cycles.
- findForeignPropertyInChain(fcd, name) — same shape for fields.
ALSO fixes a latent class-hierarchy bug uncovered while testing
M2.3: emit_llvm was passing the sx alias name to
objc_allocateClassPair(super, ...) rather than the actual Obj-C
runtime class name. For 'SxThing :: #objc_class(...) { #extends
NSObjectBase; }' where 'NSObjectBase' is aliased to "NSObject",
emit_llvm produced 'objc_getClass("NSObjectBase")' → NULL →
'objc_allocateClassPair(NULL, ...)' → SxThing's super-class link
was broken → '[sx_thing hash]' bypassed NSObject and crashed in
the forwarding machinery.
Fix: ObjcDefinedClassEntry gains a 'parent_objc_name' field
pre-resolved by lower.zig's 'resolveObjcParentName' through
foreign_class_map (which has the alias → foreign_path mapping).
emit_llvm just reads the resolved name from the entry.
153-objc-extends-chain.sx exercises both fixes:
1-level: SxThing → NSObject — t.hash() walks one #extends.
2-level: SxLeaf → SxMiddle → NSObject — chained #extends.
Both return real NSObject.hash values from libobjc.
183 example tests pass (+1). zig build test green.
Properties on sx-defined #objc_class declarations now synthesize
getter (always) and setter (unless 'readonly') IMPs that GEP into
the hidden state struct and load / store the corresponding field.
The state struct already holds every user-declared field
(objcDefinedStateStructType), so no new layout work — the IMPs
just dispatch a struct_gep + load/store through the __sx_state
ivar.
For each '#property' field on a sx-defined class:
Getter '__<Cls>_<field>_imp(self, _cmd) -> T':
state = object_getIvar(self, load(__<Cls>_state_ivar))
return state.<field>
Setter '__<Cls>_set<Field>_imp(self, _cmd, val) -> void':
state = object_getIvar(self, load(__<Cls>_state_ivar))
state.<field> = val
Both IMPs land in the cache's methods slice (mirroring the
method-IMP wiring from M1.2 A.4b.iii) so emit_llvm's
class_addMethod loop registers them on the class without
special-casing. Selector mangling:
getter: <field> (e.g. 'width')
setter: set<Field>: (e.g. 'setWidth:')
Type encoding derived from the field's resolved IR TypeId.
'readonly' (the only modifier honored in this slice) skips the
setter emission AND the corresponding method entry — so the
runtime reports the selector as absent. Other modifiers
(strong, weak, copy, assign) parse fine but stay no-ops until
M4.2 wires up ARC ops in the setter body.
152-objc-property-sx-defined.sx round-trips on macOS:
b.width = 10; b.height = 7;
read back through getter IMPs.
area is readonly — class_getInstanceMethod(SxBox, sel(setArea:))
returns NULL, confirming the setter is absent.
182 example tests pass (+1). zig build test green.
Inside a '#objc_class { ... }' block, 'name :: Type = expr;' is
accepted alongside the existing method form. Parsed as sugar for
'name :: () -> Type => expr;' — a niladic class method with an
expression body. The synthesized class method flows through the
M2.1(b) class-method pipeline: a C-ABI IMP is emitted and
registered on the metaclass.
Apple's runtime sees zero distinction — '[Cls foo]' dispatches to
our IMP regardless of source spelling. The constant form is
purely syntactic sugar; it reads better for static metadata
returns:
SxGLView :: #objc_class("SxGLView") {
layerClass :: Class = CAEAGLLayer.class();
}
vs. the equivalent method form:
layerClass :: () -> Class => CAEAGLLayer.class();
Parser change: after 'name ::' if the next token isn't '(' we
take the constant branch — parse a type expr, expect '=', parse
the value expr, expect ';'. The result is a ForeignMethodDecl
with is_static=true, empty params, return_type=Type, body=block
wrapping the expr. Pure parser-level transformation; no new AST
nodes, no new lowering passes.
150-objc-class-level-constant.sx exercises both shapes on macOS:
a primitive (s32 answer) and a pointer ('*NSObject seedClass'
— the canonical '+layerClass'-style factory return).
180 example tests pass (+1). zig build test green.
M2.1 complete: both (a) the constant form and (b) the
expression-bodied class method shape land.
Next: M2.2 — 'field: T #property(modifiers...)' synthesizes
getter/setter pairs.
Bodied methods without a '*Self' first param (parser marks
is_static=true) are now registered as Obj-C CLASS methods on
the metaclass.
Each such method gets:
- A synthesized FnDecl + body lowering through the existing
M1.2 A.2 path.
- A C-ABI trampoline 'emitObjcDefinedClassStaticImp' — same
shape as the instance trampoline but skips the __sx_state
ivar read (no instance state) and passes only
'__sx_default_context' (plus user args) to the sx body.
- An entry in ObjcDefinedMethodEntry with 'is_class=true'.
emit_llvm's class-pair init constructor now computes the
metaclass once up-front (via object_getClass(cls)) and shares
it between the +alloc IMP registration (M1.2 A.5) and the
M2.1(b) class-method registrations. The per-method registration
loop picks the target via 'method.is_class ? metaclass : cls'.
149-objc-class-method-static-imp.sx end-to-end on macOS:
SxFoo :: #objc_class("SxFoo") {
answer :: () -> s32 { return 42; }
}
// [SxFoo answer] via objc_msgSend → 42
// class_getClassMethod(SxFoo, sel_answer) → non-null
Still TODO for M2.1: the (a) class-LEVEL constant form
'layerClass :: Class = CAEAGLLayer.class();' — needs parser
extension to recognize 'name :: Type = expr;' inside #objc_class
blocks, plus lazy-init-slot synthesis.
179 example tests pass (+1). zig build test green.
Adds a special case to lowerFieldAccess: when the field is
literally 'class' and the receiver is a pointer to an Obj-C
(or Obj-C protocol) foreign-class struct, emit
'object_getClass(obj)' instead of falling through to struct GEP.
Returns 'Class' (the M1.1 first-pass alias for *void;
parameterized Class(T) covariance is deferred to M1.1.b).
f := SxFoo.alloc();
cls := f.class; // → object_getClass(f)
cls == objc_getClass("SxFoo".ptr); // ok
New helper isObjcClassPointer(ty) detects 'ptr -> struct in
foreign_class_map under .objc_class / .objc_protocol'. The
check fires BEFORE the auto-deref so the runtime call sees the
opaque Obj-C pointer rather than the load'd struct stub.
148-objc-self-class-accessor.sx exercises both shapes end-to-end
against the macOS runtime: sx-defined class (SxFoo) and foreign
class (NSObject). Round-trips against objc_getClass(name).
178 example tests pass. zig build test green.
This effectively closes Month 1 — M1.0, M1.1 (first pass), M1.2,
M1.3 all done. Remaining: M1.1.b (Class(T) covariance +
instancetype), then Month 2 (declarative sugar).
Delete the bail at lower.zig:4407 that diagnosed sx-defined Obj-C
class dispatch as 'not yet supported'. Both foreign and
sx-defined '#objc_class' decls now flow through the same
'lowerObjcMethodCall' path — instance methods on sx-defined
classes dispatch via objc_msgSend, and the registered IMP
trampolines (M1.2 A.4b.iii) route to the sx bodies.
The runtime non-Obj-C branch (.swift_class / .swift_struct /
.swift_protocol) keeps its 'not yet supported' diagnostic;
M1.2 only addresses the Obj-C runtimes.
Constructor reorder in emit_llvm: emitObjcDefinedClassInit
runs BEFORE emitObjcClassInit. Otherwise the Phase 3.1
class-cache populator calls objc_getClass("SxFoo") before our
constructor registers the class — cache slot stored null and
'SxFoo.method()' dispatched against a null class pointer.
ffi-objc-defined-class-01-instance.sx (the integration test
from the plan) now runs the full lifecycle on macOS:
f := SxFoo.alloc() // synthesized +alloc IMP fires
f.bump() // dispatch → IMP trampoline → sx body
f.bump() // state persists across calls
f.bump()
f.get() // → 3
release_fn(f, sel_release) // synthesized -dealloc fires
The user declares 'alloc :: () -> *SxFoo;' bodyless to give the
synthesized +alloc IMP a typed contract at sx call sites —
same convention as foreign classes today.
M1.2 complete: A.0 A.1 A.2 A.3 A.4 A.4b.i A.4b.ii A.4b.iii
A.5 A.6 A.7. End-to-end class-synthesis foundation works.
177 example tests pass (+1 from the integration test). zig
build test green.
For every sx-defined #objc_class, emit a C-callconv -dealloc IMP
that runs at refcount-zero. Frees the sx state struct, nils the
ivar, then chains to [super dealloc] so NSObject's runtime
cleanup (object_dispose, associated-object teardown, KVO, etc.)
runs as usual.
-dealloc IMP (self: id, _cmd: SEL) -> void
state = object_getIvar(self, load @__<Cls>_state_ivar)
free(state) // free(NULL) is safe
object_setIvar(self, ivar, NULL)
sup = alloca { receiver: *void, super_class: *void }
sup.receiver = self
sup.super_class = load @__<Cls>_class
sel_dealloc = sel_registerName("dealloc")
objc_msgSendSuper2(&sup, sel_dealloc)
return
Two new per-class globals:
- '__<Cls>_class' : *void — populated by emit_llvm's
class-pair init constructor with the freshly-allocated Class
pointer (after objc_registerClassPair).
- The existing '__<Cls>_state_ivar' is also consulted to find
the state struct.
The -dealloc IMP is registered on the class itself (instance
method) via class_addMethod with encoding 'v@:'. emit_llvm
ALSO stores cls_val into '__<Cls>_class' so the trampoline
can build the objc_super struct.
internStringConstantGlobal helper added to lower.zig — interns
C strings as [N:0]u8 globals with byte-level aggregate inits.
Used here for the 'dealloc' selector string.
147-objc-class-dealloc-roundtrip.sx verifies end-to-end on
macOS: alloc + release fires the IMP, and a second alloc/release
cycle proves runtime state isn't corrupted. class_getMethod-
Implementation confirms the IMP is registered.
176 example tests pass (+1). zig build test green.
Still gated: sx-side 'obj.method()' calls bail at lower.zig:4407
with the existing diagnostic. A.7 opens the gate — last sub-step
of M1.2.
For every sx-defined #objc_class, emit a C-callconv +alloc IMP
that the Obj-C runtime calls when '[Cls alloc]' fires (from sx
code, UIKit instantiation, Info.plist principal class, etc.):
+alloc IMP (cls: Class, _cmd: SEL) -> id
instance = class_createInstance(cls, 0)
state = malloc(STATE_SIZE)
memset(state, 0, STATE_SIZE)
object_setIvar(instance, load(@__<Cls>_state_ivar), state)
return instance
STATE_SIZE = max(typeSizeBytes(state struct), 1) — always at
least one byte so the ivar is never null after +alloc returns.
The IMP is registered on the METACLASS (class methods live there
— every Class object's isa points to the metaclass) in emit_llvm's
class-pair init constructor:
metaclass = object_getClass(cls)
sel_alloc = sel_registerName("alloc")
class_addMethod(metaclass, sel_alloc, alloc_imp, "@@:")
That override wins over NSObject's default +alloc; runtime
instantiations get the __sx_state ivar bound automatically.
Per-instance allocator binding (the plan's full design — store
the Allocator value in the state struct so -dealloc frees through
the same one) is deferred. libc malloc/free is fine for v1; we'll
upgrade once Month 4's autoreleasepool + ARC ops shake out.
REFACTOR: collapsed five duplicate 'get<Name>Fid' helpers and
their cache fields (object_getIvar, object_setIvar,
class_createInstance, malloc, memset) into a single
'ensureCRuntimeDecl(name, params, ret) -> FuncId'. The helper
checks for an existing decl by name first (avoids the
'class_createInstance.1' duplicate-symbol crash when stdlib's
'#foreign' decl is already in the module). One helper instead
of one-per-function = ~150 lines deleted.
object_getIvar / object_setIvar added to stdlib std/objc.sx
so user code can use them too (146 exercises object_getIvar
to verify __sx_state was bound to a non-null state pointer
after +alloc).
146-objc-class-alloc-roundtrip.sx end-to-end against macOS:
'[SxFoo alloc]' returns non-null AND object_getIvar(instance,
__sx_state) returns the state ptr. Real Obj-C runtime, no
mocks.
175 example tests pass (+1). zig build test green.
For each instance method on a sx-defined '#objc_class', the
class-pair init constructor now:
sel = sel_registerName("selector_string")
imp = @__<Cls>_<method>_imp (M1.2 A.4b.ii)
class_addMethod(cls, sel, imp, "<encoding>")
before objc_registerClassPair. The IMP trampoline (A.4b.ii)
already bridges C-ABI -> sx body. With registration in place,
'objc_msgSend(obj, sel_bump)' now routes to the trampoline,
which reads __sx_state ivar and forwards to '@<Cls>.<method>'.
To get selector + type-encoding strings out of lower.zig and
into emit_llvm, ObjcDefinedClassEntry gains a 'methods' slice:
pub const ObjcDefinedMethodEntry = struct {
sel: []const u8, // mangled selector (M1.2 A.1's deriveObjcSelector)
encoding: []const u8, // type encoding (M1.2 A.1's objcTypeEncodingFromSignature)
imp_name: []const u8, // C-callconv trampoline symbol
};
registerObjcDefinedClassMethods populates this when it declares
each method's body function; Module.setObjcDefinedClassMethods
attaches the slice to the cache entry by name. Static (class-
side) methods are skipped — A.4b only covers instance methods;
class-method hooks like '+layerClass' land in M2.1.
emit_llvm reads entry.methods and emits class_addMethod inside
the per-class init block, before objc_registerClassPair (the
runtime locks the method list at register time on some SDK
versions).
145-objc-class-method-dispatch.sx verifies end-to-end:
class_getMethodImplementation(SxFoo, sel_registerName("bump"))
returns non-null after main starts. Both niladic ('bump') and
single-arg ('add:') selectors checked.
Still gated (A.7): sx-side 'obj.bump()' calls. The dispatch
gate at lower.zig:4407 hasn't opened — A.5 (+alloc) and A.6
(-dealloc) need to land first so the integration test
ffi-objc-defined-class-01-instance.sx (full state round-trip)
can exercise the full lifecycle.
174 example tests pass (+1 from 145). zig build test green.
For each bodied instance method on a sx-defined #objc_class,
emit a C-callconv trampoline function '__<Cls>_<method>_imp':
void __SxFoo_bump_imp(ptr obj, ptr _cmd, ...user_args) {
ivar = load @__SxFoo_state_ivar
state = object_getIvar(obj, ivar)
call @SxFoo.bump(__sx_default_context, state, ...user_args)
ret
}
The trampoline bridges the Obj-C runtime's IMP calling convention
('id self, SEL _cmd, ...args' as C ABI) to the sx body's
default-callconv shape ('__sx_ctx ptr, state ptr, ...user_args').
Implicit context comes from '&__sx_default_context'; the body
keeps its sx-side personality intact and can use 'self.field'
through the substituted state-struct pointer (M1.2 A.2b + A.3).
New helpers in lower.zig:
- 'getObjcObjectGetIvarFid' lazily declares object_getIvar.
- 'emitObjcDefinedClassImps' + 'emitObjcDefinedClassImp' walk the
cache and synthesise each trampoline.
- 'lookupGlobalIdByName' for finding the per-class ivar handle
global. Linear scan — same N-is-small rationale as the other
Obj-C caches.
Dead code at this commit: the trampolines exist in the module
but no class_addMethod call registers them with the runtime.
'objc_msgSend(obj, sel_bump)' would still fall through to the
parent class (NSObject 'doesNotRecognizeSelector:') today.
A.4b.iii wires up class_addMethod in emit_llvm's class-pair-init
constructor — that's when the trampolines come alive.
142's IR snapshot refreshed to show the trampoline.
173 example tests pass. zig build test green.
Class-pair init constructor now registers a single hidden ivar
on each sx-defined class:
class_addIvar(cls, "__sx_state", 8, 3, "^v")
before objc_registerClassPair. After the class is registered,
the constructor calls class_getInstanceVariable to fetch the
runtime Ivar handle and stores it in a per-class global
'__<ClassName>_state_ivar : *void'. Trampolines (A.4b.ii) will
read this global to 'object_getIvar' the state struct pointer.
lower.zig declares the per-class global at scan time
(declareObjcDefinedStateIvarGlobal) so emit_llvm finds it by
name when populating. Encoding '^v' = void* (a generic pointer
— the runtime treats it as opaque storage). log2 alignment = 3
for 8-byte pointer alignment on 64-bit.
144-objc-class-ivar-registration.sx exercises the round-trip:
after main starts, class_getInstanceVariable(SxFoo, "__sx_state")
returns non-null. Runs against the real Obj-C runtime on macOS.
142's IR snapshot refreshed to include the new constructor body
(class_addIvar + class_getInstanceVariable + ivar-global store).
173 example tests pass (+1 from 144). zig build test green.
For every sx-defined '#objc_class', emit a module-init constructor
that registers the class with the Obj-C runtime at module load.
Pattern mirrors the Phase 3.1 emitObjcClassInit companion:
'@llvm.global_ctors' + ORC-JIT main injection.
Constructor body, per cache entry:
super = objc_getClass("<ParentName>") // default NSObject
cls = objc_allocateClassPair(super, "<ClassName>", 0)
objc_registerClassPair(cls)
Parent is read from the foreign_class_decl's '.extends' member;
absent ⇒ NSObject (matches M1.2 A.0 spec). Class-name strings
go through new emitPrivateCString helper that mirrors the
selector-init / class-init shape.
Two new small helpers extracted while we were here:
- lazyDeclareCRuntime — declare-once extern wrapper for Obj-C
runtime APIs.
- appendModuleCtor — append-or-create global_ctors + ORC-JIT
injection, factored out of emitObjcClassInit.
143-objc-class-registration.sx exercises the round-trip on
macOS: after main starts, objc_getClass("SxFoo".ptr) returns
non-null. Runs against the real Obj-C runtime.
142's IR snapshot updated — the constructor + ctors metadata
are now part of the expected shape.
DEFERRED (A.4b): method-IMP registration (class_addMethod with
a C-ABI trampoline that reads __sx_state ivar and calls the sx
body). DEFERRED (A.5+): synthesized +alloc / -dealloc IMPs and
the '__sx_state' ivar setup.
172 example tests pass (+1 from 143). zig build test green.
Adds Pass 4b 'lowerObjcDefinedClassMethods' to lowerRoot: after
scan, walk objc_defined_class_cache and force-lower each bodied
instance method. The Obj-C runtime invokes these via the IMP
pointers wired up in A.4 — no sx-side call path drives lazy
lowering, so we trigger it here. Mirrors the JNI eager-lower
pattern in Pass 5.
Bug fix: lazyLowerFunction has its OWN inline body-lowering
path (separate from lowerFunction) that re-resolves param types
at line 1025. It was running without current_foreign_class set,
so '*Self' fell through to the type_bridge fallback and got
interned as a 0-field struct named 'Self' — body's
'self.counter' GEP'd into '{}' and LLVM verification rejected.
Fix: set current_foreign_class at the top of lazyLowerFunction
via the same lookupObjcDefinedClassForMethod path lowerFunction
uses. Save+restore via defer.
A.3 ('self.field access via the ivar') falls out for free —
'*Self' resolves to '*__SxFooState' so 'self.counter' is a
plain struct field access. IR snapshot in
142-objc-class-method-lowering.ir shows the round-trip:
define internal void @SxFoo.bump(ptr, ptr self) {
%gep = getelementptr inbounds { i32 }, ptr %self, 0, 0
%v = load i32, ptr %gep
store i32 (%v + 1), ptr %gep
ret void
}
171 examples pass (+1 from 142); zig build test green.
Still gated: Obj-C runtime dispatch (A.7) — sx-side
'f.bump()' calls bail at lower.zig:4407 with the existing
diagnostic. IMP-trampoline emission (the C-ABI shim that bridges
'objc_msgSend' → this body) lands in A.4 alongside class-pair
init.
Bodied instance methods on a sx-defined '#objc_class("Cls") { ... }'
declaration are now registered in fn_ast_map under '<Cls>.<method>'
and declared in the IR with their *Self params substituted to
the hidden state-struct type (M1.2 A.2a).
registerObjcDefinedClassMethods walks the foreign_class_decl's
members, synthesizes an FnDecl from each ForeignMethodDecl (zipping
params + param_names), and feeds it through declareFunction with
current_foreign_class temporarily pinned so resolveTypeWithBindings
substitutes Self → __SxFooState.
resolveTypeWithBindings now treats type_expr 'Self' as a contextual
alias: when current_foreign_class points to a sx-defined Obj-C
class, the substitution returns objcDefinedStateStructType(fcd).
Other Self contexts (protocols, JNI super, foreign-class member
type resolution) are untouched — the check filters on (!is_foreign
and runtime == .objc_class).
lowerFunction also sets current_foreign_class for the duration of
the body lowering when the name is qualified <Cls>.<method> and
Cls is in objc_defined_class_cache. Save+restore via defer so
nested calls round-trip cleanly.
Verification (manual): 'sx ir' on an sx-defined class shows
'declare void @SxFoo.bump(ptr, ptr)' — two args = implicit
__sx_ctx + the state-struct pointer (correct *Self substitution).
Body emission happens lazily; A.2c will trigger it eagerly so
the IMP trampoline (A.4) can reference it.
170 example tests + zig build test green.
Builds (and interns) the hidden sx-state struct type for an
sx-defined '#objc_class'. Layout:
__<ClassName>State {
user_field_0,
user_field_1,
...
}
This struct is what the runtime's '__sx_state' ivar points at —
separate from the Obj-C object itself, which stays opaque. The
sx method bodies will operate on '*__SxFooState' (after '*Self'
substitution in A.2b) so 'self.field' resolves to a plain struct
field access — A.3's 'free if types align' premise.
M1.2 A.5 will prepend '__sx_allocator: Allocator' so dealloc can
free through the per-instance allocator. Field-by-name access
stays correct across the future repositioning.
Methods / '#extends' / '#implements' members are ignored — only
'.field' contributes. Three unit tests pin: typical-field case,
empty-class case, mixed-member case.
Dead code at this commit — helper isn't called yet. A.2b (body
lowering with '*Self' substitution) wires it in. 170 example
tests + zig build test green.
Derives Apple's runtime type-encoding string from an IR method
signature. Called by class_addMethod(cls, sel, imp, types) when
M1.2 A.4+ synthesise IMPs for sx-defined classes.
Layout: <ret> @ : <param0> <param1> ... — @ is the receiver,
: is _cmd. Caller passes user-declared params AFTER stripping
'self: *Self'.
Encoding table:
v=void B=bool c=s8/BOOL s=s16 i=s32 q=s64
C=u8 S=u16 I=u32 Q=u64 f=f32 d=f64
@=foreign Obj-C class ptr #=Class :=SEL
*=[*]u8 (C string) ^v=any other ptr
bool (sx i1) maps to 'B' (C99 _Bool); s8 to 'c' (Apple's BOOL).
Foreign-class pointers detected via foreign_class_map lookup on
the pointee struct name. Other pointers fall to ^v — encoding is
metadata, not ABI, so conservative is safe.
Struct / slice / closure / etc. BAIL via diagnostic
(ObjcEncodingUnsupported) rather than silently mis-encoding, per
CLAUDE.md rejected-patterns rule. Future passes will widen the
table as new shapes show up in real IMPs.
Dead code at this commit — helper isn't called yet. Three unit
tests in src/ir/lower.test.zig pin the primitive / pointer /
Obj-C-class-pointer encodings before A.2 wires the helper in.
170 example tests + zig build test green.
Adds an insertion-ordered cache on Module for sx-defined Obj-C
classes — every '#objc_class("Cls") { ... }' declaration WITHOUT
'#foreign'. registerForeignClassDecl appends the entry alongside
its existing foreign_class_map insert; lookup helper available
via Module.lookupObjcDefinedClass.
ObjcDefinedClassEntry { name, *const ast.ForeignClassDecl }
The pointer back into the AST lets later passes (M1.2 A.1+) walk
'members' for fields / methods / '#extends' / '#implements'
without duplicating that data on the entry. Insertion order
matters because class-pair init constructors (A.4) must register
parent classes before children — 'objc_allocateClassPair(super,
...)' resolves super by lookup.
Infrastructure only — no observable behavior change. The cache
is populated but not yet read; A.1+ start pulling from it. 170
example tests + zig build test green.
Adds named stand-ins for the three opaque Obj-C runtime types
and Apple's signed-char boolean to library/modules/std/objc.sx:
id :: *void; // any Obj-C instance pointer
Class :: *void; // a class object pointer
SEL :: *void; // a registered selector
BOOL :: s8; // Apple's signed-char boolean (NOT sx's bool)
All resolve to their underlying type at the LLVM layer — no
runtime cost — but make foreign-class declarations read closer
to Objective-C source. The header's old caveat about lacking
type aliases is gone.
141-objc-type-aliases.sx exercises the aliases against the real
macOS Obj-C runtime: alloc/init an NSObject, fetch its class
via objc_getClass, sel_registerName a SEL, then call
'isKindOfClass:' returning BOOL=1. Non-macOS paths print the
same line to keep the snapshot stable.
DEFERRED (M1.1.b, follow-up): 'Class(T)' parameterization with
#extends-aware covariance, and 'instancetype' per-decl
substitution. Both require compiler-level type-check support
beyond plain stdlib aliases.
170 examples pass (+1).
Extends parseForeignClassDecl ([src/parser.zig:1262]) with an
arrow arm that mirrors the existing parseFnDecl shape — single-
expression body wrapped in a one-statement block so downstream
lowering sees the same AST as a brace-body method.
Closes the M1.0 surface: '=> expr;' is now valid for top-level
functions, struct methods, AND '#objc_class' member methods.
The sx-defined class lowering (A.7 dispatch gate) is still gated,
so 140-expression-bodied-objc-method.sx exercises parse-only —
the body is reachable but the method is never invoked. When M1.2
lights up sx-defined class instantiation, the arrow-body form
will flow through unchanged.
169 examples pass (+1 from 140 now green); zig build test green.
parseForeignClassDecl ([src/parser.zig:1262]) accepts ';'
(declaration) or '{ ... }' (block body) but not '=>' for member
methods. The arrow form, which parseFnDecl ([src/parser.zig:1647])
already handles for top-level/struct decls (M1.0 1/3), surfaces
'expected ;' at the arrow today.
Snapshot pins that error so the next commit (the parser
extension) shows up as a single diagnostic→runtime-output diff
in 140-expression-bodied-objc-method.{txt,exit}.
sx's '=>' body form (already used for lambdas) works today for
top-level function declarations and struct member methods. Pin
the surface with examples/139-expression-bodied-fn.sx so a
parser regression here surfaces immediately.
Coverage:
- module-top: double :: (x: s32) -> s32 => x * 2;
- niladic: answer :: () -> s32 => 42;
- struct method: total :: (self: *Point) -> s32 => self.x + self.y;
Next: extend the same form to '#objc_class' member methods (the
M2.1(a/b) class-constant + class-method overrides path).
`inferExprType` for a chained call `Cls.static().instance(...)` never
looked the inner call's foreign-class declaration up, so the outer
dispatch saw a `.s64` receiver, the `foreign_class_map.get(...)` lookup
missed, and lowering emitted `error: unresolved 'method'`. The macOS
target appeared to work because `inline if OS == .ios { ... }` strips
the gated body before lowering — eliding every call that would have
exercised the broken path.
The "lazy-lower" framing in the original issue file was a red herring.
Fix in `src/ir/lower.zig`:
1. `inferExprType` for `.call` with `.field_access` callee now checks
`foreign_class_map` for both shapes — `Cls.static_method(args)` (object
identifier matches a foreign-class alias, look up static members) and
`inst.instance_method(args)` (receiver is a pointer to a foreign-class
struct, look up non-static members).
2. New helpers `resolveForeignMethodReturnType` and
`resolveForeignClassMemberType` substitute `*Self` / `Self` to the
foreign-class struct so a `*Self` return doesn't synthesize a phantom
`Self`-named struct that future dispatches can't resolve.
3. The Obj-C lowering paths (`lowerObjcMethodCall`, `lowerObjcStaticCall`)
route through the same helper for `ret_ty` so the IR Ref's type matches
what `inferExprType` reports.
Regression test at `examples/138-foreign-class-chained-dispatch.sx`
exercises NSObject's `+alloc` / `-init` chain in both shapes —
`*NSObject` return then `*Self` return, and `*Self` then `*Self`. Runs
on the host (macOS) for live exercise; non-macOS hosts fall through to
a stub matching the expected output.
This unblocks Phase 3.2 C4/C5 — the `UIWindow.alloc().initWithWindowScene(scene)`
pattern that surfaced the bug is the cluster's bread-and-butter shape.
167/167 example tests; chess builds clean on macOS, iOS-sim, Android.
Per CLAUDE.md IMPASSIBLE RULES. Attempted Phase 3.2 C4 migration of
the UIKit chrome cluster in `library/modules/platform/uikit.sx`
(UIScreen / UIWindow / UIViewController / UITextField / UIView)
surfaced a real compiler bug: when a function body contains
`recv.method(...)` calls against an `#objc_class` receiver AND that
body is reached via `lazyLowerFunction` invoked from another
`inline if OS == ...` branch, the method dispatch fails with
"unresolved 'methodName'".
Specifically: `uikit_scene_will_connect_ios` (the iOS-sim crashing
case) contains `UIWindow.alloc().initWithWindowScene(scene)` etc.
The same calls compile cleanly in isolated probes — only the lazy-
lower-via-inline-if entry chain reproduces the bug. macOS target
builds fine throughout; ios-sim trips it.
C1/C2/C3 (commits 1ea9cda / 17775b2 / 2a7c8e0) happen to land cleanly
because the methods they migrate are reached eagerly (or are niladic
so the dispatch path doesn't hit the failing branch). C4 + C5 stay
blocked pending issue-0043's fix in a separate session.
Issue filed at `issues/0043-lazy-lower-loses-foreign-class-method-dispatch.md`
with the reproduction, stack trace, and investigation prompt
pointing at `lower.zig:1057` (`lazyLowerFunction`) and
`lower.zig:5290` (the field-access foreign-class dispatch chain).
FFI checkpoint updated to mark C4+C5 as BLOCKED on 0043.
The in-progress C4 working-tree changes were reverted; tree is at
the C3 commit `2a7c8e0` and chess on macOS/iOS-sim/Android builds
cleanly.
Third cluster: NSRunLoop and CADisplayLink move to declarative
`#objc_class` blocks.
Classes declared:
- NSRunLoop → currentRunLoop (class)
- CADisplayLink → displayLinkWithTarget_selector (class),
addToRunLoop_forMode (instance),
targetTimestamp (instance), duration (instance)
The display-link instance is created with the new typed call shape:
link := CADisplayLink.displayLinkWithTarget_selector(plat.gl_view, sel_tick);
plat.display_link = xx link; // keep the *void slot in the
// platform struct for ABI parity
runloop := NSRunLoop.currentRunLoop();
link.addToRunLoop_forMode(runloop, mode_ns);
The `sxTick:` callback's `link: *void` param is cast to
`*CADisplayLink` at function entry so the body's `link.duration()` /
`link.targetTimestamp()` calls type-check.
Two now-redundant `objc_getClass(...)` lookups for CADisplayLink /
NSRunLoop are gone — the class slots come from the declarative
declarations via `__sx_objc_class_init`.
166/166 tests; chess builds clean on macOS / iOS / Android.
Second cluster: NSNotification, NSBundle, NSNotificationCenter move
from `#objc_call(T)(recv, "sel:", args)` to declarative
`#foreign #objc_class("Cls") { ... }` blocks.
Classes declared (alongside the C1 Foundation utility group):
- NSNotification → userInfo (instance, returns *NSDictionary)
- NSBundle → mainBundle (class), resourcePath (instance)
- NSNotificationCenter → defaultCenter (class),
addObserver_selector_name_object (instance)
The 4-keyword `addObserver:selector:name:object:` selector derives
from the underscore-separated sx name via the default mangling rule
— no `#selector` override needed.
Cleanup wins:
- `objc_getClass("NSBundle")` and `objc_getClass("NSNotificationCenter")`
call sites gone — class slots now populated by emit_llvm's
`__sx_objc_class_init` constructor.
- `userInfo`'s return type is `*NSDictionary` directly, so the
previous `*void → *NSDictionary` cast at the keyboard-frame
callsite collapses.
166/166 tests; chess builds clean on macOS + iOS + Android via
`sx build main.sx`.
First of five Phase-3.2 migration clusters. Foundation utility
classes (NSValue, NSNumber, NSDictionary, NSMutableDictionary, NSSet)
in `library/modules/platform/uikit.sx` move from the explicit
`#objc_call(T)(recv, "selector:", args)` form to declarative
`#foreign #objc_class("Cls") { ... }` blocks with `recv.method(args)`
dispatch.
Classes declared (all near the top of uikit.sx, after the CGRect
struct):
- NSValue → CGRectValue (instance)
- NSNumber → numberWithBool (class), doubleValue +
unsignedLongValue (instance)
- NSDictionary → objectForKey (instance)
- NSMutableDictionary → dictionary (class), setObject_forKey (instance)
- NSSet → anyObject (instance)
Each call site casts the `*void` receiver to the typed foreign-class
pointer before dispatch — the existing `*void` shape is preserved
in the struct fields and outer parameter types; only the dispatch-
local copy is typed. This keeps the diff scoped to call-site
rewrites without rippling type changes through every consumer.
The four `objc_getClass(...)` calls that previously resolved
NSMutableDictionary / NSNumber at runtime are gone — Phase 3.1's
`__sx_objc_class_init` constructor populates the cached class slot
for each declared `#objc_class` at module load via
`OBJC_CLASSLIST_REFERENCES_<Cls>`.
166/166 example tests; chess clean on macOS + Android via
`tools/verify-step.sh` (iOS sim skipped — no booted simulator in
this run; previous full run was green at HEAD~6).
`examples/ffi-objc-dsl-07-mangling-table.sx` exercises every common
mangling shape in one fixture and pins the resolved selectors via
both `.txt` and `.ir` snapshots:
| sx method | derived selector |
|-----------------------------------|----------------------------|
| `length` | `length` |
| `addObject(o)` | `addObject:` |
| `combine_and(a, b)` | `combine:and:` |
| `insert_after_index(a, b, c)` | `insert:after:index:` |
| `add_observer_for_event(a, b, c, d)` | `add:observer:for:event:` |
| `initWithFrame_options(f, o)` | `initWithFrame:options:` |
| `custom_name #selector("actualSelectorName")` | `actualSelectorName` |
The class is synthesized at runtime via `objc_allocateClassPair` +
`class_addMethod` per selector (mirrors the pattern in
`ffi-objc-dsl-{01..05}.sx`), so the test actually dispatches through
the real Obj-C runtime on macOS.
Single commit because the implementation already shipped in 3.0/3.2;
this is a new regression that locks in current behavior, not a
test-then-make-green pair.
The `.ir` snapshot opts in via the existing run_examples.sh mechanism
(presence of a `.ir` file for the same name triggers capture). The
captured `OBJC_METH_VAR_NAME_*` constants surface every selector
string change at a glance.
166/166 tests.
Make-green half of the cadence step started in A1. Wires the
`#selector` directive end-to-end:
- Lexer token `hash_selector` at src/token.zig + lookup row in
src/lexer.zig.
- AST field `selector_override: ?[]const u8 = null` on
`ForeignMethodDecl` (src/ast.zig).
- Parser block in src/parser.zig that mirrors
`#jni_method_descriptor` — both occupy the same slot after the
optional `-> ReturnType` and before the body/terminator. Not
mutually exclusive at parse time.
- LSP semantic-token list (src/lsp/server.zig) updated.
- Lowering: `deriveObjcSelector` returns
`{ sel, keyword_count, is_override }`. When `is_override` is true,
the selector string is the user's literal and `keyword_count` is
the colon count in that literal. Both `lowerObjcMethodCall` and
`lowerObjcStaticCall` use the result.
Diagnostic policy when override colon-count ≠ call arity:
- Default mangling path: stays an error (`.err`). The user can fix
the sx-side name to produce the right keyword count.
- Override path: downgrades to a warning (`.warn`). Rationale:
Obj-C's `objc_msgSend` doesn't validate colon-vs-arg the way JNI's
`GetMethodID` validates the descriptor — the runtime dispatches
regardless and the wrong-arity case becomes silent calling-
convention corruption. The compiler is the last line of defense
for this typo class, but the warning preserves the override's
escape-hatch character (deliberate mismatches still proceed).
Snapshot for `examples/ffi-objc-dsl-06-selector-override.sx` flips
from the pre-3.2 parser-error to working output:
static override non-null: true
The mismatch diagnostic text in
`examples/ffi-objc-dsl-04-mismatch.sx`'s snapshot is updated to
drop the "once that lands (3.2)" phrasing now that 3.2 is here.
165/165 example tests.
Phase 3.2 xfail half. `#selector("explicit:string")` is the escape
hatch for cases where the sx-side method name doesn't conveniently
produce the target selector under the default mangling rule
(Phase 3.0 — split on `_`, each piece becomes a keyword with a
trailing `:`).
Surface form mirrors `#jni_method_descriptor("(Sig)Ret")` — sits
after the optional `-> ReturnType` and before the method body /
terminator.
Test fixture covers both lowering paths:
- Static method override: `NSObject.gimme()` with override
"description" — exercises lowerObjcStaticCall (Phase 3.1).
- Instance method override: `NSDictionary.lookup(self, key)` with
override "objectForKey:" — declared (parse + AST + lowering
wiring) but not invoked at runtime (no real NSDictionary in
scope). The declaration alone locks in the multi-arg-override path.
Pre-3.2: parser doesn't know `#selector`; snapshot captures
"expected ';'" at the override site, exit=1. Next commit (A2) wires
the lexer token, AST field, parser block, and lowering integration;
snapshot flips to working output.
165/165 example tests. Plan at
`~/.claude/plans/lets-see-options-for-merry-dijkstra.md`.
`static name :: ...` was redundant — instance methods always declare
`self: *Self` as their first param by convention. The parser now derives
`is_static` from the first param's TYPE: if it's `*Self` the method is
an instance method; anything else (including no params at all) is a
class method. Removes a token from the surface, keeps the dispatch
behavior identical.
The receiver param's NAME doesn't matter — only its type. Calling the
first param `this`, `me`, `receiver`, etc. is fine as long as the type
is `*Self`. This mirrors how the rest of sx handles receiver dispatch.
Migration of every site that used the keyword:
- `library/modules/platform/android.sx` — `SurfaceView.new(ctx)`.
- `examples/ffi-jni-class-03-static.sx` — `Math.abs(n)`.
- `examples/ffi-jni-main-03-ctor.sx` — `SurfaceView.new(ctx)` in the
`#jni_main` body.
- `examples/ffi-objc-dsl-05-static.sx` — NSObject's `.class()` /
`.description()`.
164/164 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
Implementation half of the Phase 3.1 cadence step.
`lowerForeignStaticCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcStaticCall` helper
that loads the class object from a module-scoped cached slot (populated
once per module via `objc_getClass`) and dispatches `objc_msg_send`
with the same selector-mangling as Phase 3.0's instance dispatch.
Three pieces:
1. `Module.objc_class_cache` — parallel to `objc_selector_cache`,
insertion-ordered list of (class_name, slot_GlobalId) so the
constructor that calls `objc_getClass` per slot at module load
is deterministic. `lookupObjcClass` / `appendObjcClass` accessors.
2. `internObjcClassObject` in lower.zig — get-or-create a
`OBJC_CLASSLIST_REFERENCES_<Cls>` global pointer; matches clang's
naming convention. `lowerObjcStaticCall` reuses
`deriveObjcSelector` from 3.0 for the selector, loads the class
slot, and emits `objc_msg_send(class_obj, sel, args)`.
3. `emitObjcClassInit` in emit_llvm.zig — companion to
`emitObjcSelectorInit`. Walks `objc_class_cache`, synthesizes a
constructor `__sx_objc_class_init` that calls `objc_getClass(name)`
per slot, registers in `@llvm.global_ctors` for AOT (extending the
existing array if the selector init already created it), and
injects a direct call into main's prelude after any prior init
calls so the ORC JIT path runs it too.
Surface form is `.` (`NSObject.class()`) matching JNI's `Alias.new(...)`
convention rather than the plan's notional `::` — avoids extending the
parser for a new postfix operator with no other use case.
Test `examples/ffi-objc-dsl-05-static.sx` exercises NSObject's
`+class` and `+description` class methods via the new syntax, asserts
both return non-null. NSObject is always available at module-load,
unlike runtime-created test classes that wouldn't exist yet when
the class-init constructor runs.
164/164 tests; chess builds + runs clean on all three platforms.
xfail half of Phase 3.1: static calls on `#objc_class` aliases lower
to `objc_msg_send` against the class object (loaded once per module
via `objc_getClass`).
Test mirrors the Phase 3.0 pattern (`ffi-objc-dsl-01..04`): synthesize
a class at runtime via `objc_allocateClassPair`, add class methods on
the metaclass via `object_getClass(cls) + class_addMethod`, declare
the sx-side `#objc_class` with `static answer :: ...` / `static add ::
...`, then invoke `SxProbeStatic.answer()` / `.add(7, 35)`. Skips on
non-macOS.
Surface choice: the call site is `.` (`Cls.method(args)`), matching
JNI's existing static dispatch convention (`SurfaceView.new(ctx)`)
rather than the plan's notional `::` form. The lowering disambiguates
static vs instance by inspecting `method.is_static` on the foreign-
class member, same as JNI. Picking `.` avoids extending the parser
for a new postfix operator with no other use case.
Pre-3.1 snapshot pins the current bail diagnostic at
`lowerForeignStaticCall` (lower.zig:4475) — "static calls on
'objc_class' runtime not yet supported (Phase 3/4)" — fires twice
because both the niladic and the keyword-arg static call hit it.
exit=1.
164/164 tests; next commit implements the dispatch and flips the
snapshot to working output.
Implementation half of the cadence step started in the previous commit.
`lowerForeignMethodCall` for `#objc_class` / `#objc_protocol` runtimes
no longer bails; it routes through a new `lowerObjcMethodCall` helper
that derives the Obj-C selector from the sx method name and lowers to
`objc_msg_send` against the cached SEL slot (same intern path as
explicit `#objc_call`).
Default selector mangling (matches clang's keyword-method convention):
- Niladic (arity 0 excluding self): name verbatim. `length()` → "length".
- Arity ≥ 1: split the sx method name on `_`; each piece becomes a
keyword with a trailing `:`. `addObject(o)` → "addObject:";
`combine_and(a, b)` → "combine:and:";
`initWithFrame_options(f, o)` → "initWithFrame:options:".
Arity validation: keyword count (pieces from the `_`-split) must equal
call-site arity excluding self. Mismatch diagnoses at the call site
with a hint pointing at the forthcoming `#selector("...")` override
(Phase 3.2) for selectors that don't fit the underscore-split rule.
Mangling helper `deriveObjcSelector` and dispatch helper
`lowerObjcMethodCall` sit alongside `lowerForeignMethodCall`. The
existing fall-through diagnostic for non-JNI/non-Obj-C runtimes
remains for Swift (Phase 4 territory).
Tests `examples/ffi-objc-dsl-{01-niladic,02-one-arg,03-multi-keyword,
04-mismatch}.sx` snapshots flip from the pre-3.0 bail diagnostic
(exit=1) to working output (exit=0 for cases 01-03) and the specific
keyword-count mismatch diagnostic for case 04. Each test follows the
established pattern from `ffi-objc-call-08-multi-keyword.sx`:
synthesize a class at runtime via `objc_allocateClassPair` /
`class_addMethod`, declare a matching `#objc_class`, invoke the DSL
form. 163/163 tests; chess unaffected (JNI dispatch path untouched).
The previous FFI checkpoint claimed Phase 3 step 3.0 ("`inst.method(args)`
on #objc_class receivers") had landed. It hadn't — `lowerForeignMethodCall`
in lower.zig:4353 still bails for any non-JNI runtime with the generic
"method calls on '{runtime}' runtime not yet supported (Phase 3/4)"
diagnostic, no commit introduced an Obj-C DSL dispatch path, and the
planned regression files weren't on disk.
This commit is the xfail half of the proper cadence (test-add then
make-green in separate commits):
- examples/ffi-objc-dsl-01-niladic.sx — `length()` → selector "length".
- examples/ffi-objc-dsl-02-one-arg.sx — `addObject(o)` → "addObject:".
- examples/ffi-objc-dsl-03-multi-keyword.sx — `combine_and(a, b)` →
"combine:and:" (sx name split on `_`, each piece becomes a keyword
with a trailing `:`).
- examples/ffi-objc-dsl-04-mismatch.sx — `something_extra(x)` —
keyword count (2) ≠ arity (1); must diagnose at the call site.
Each test follows the same pattern as `ffi-objc-call-08-multi-keyword.sx`:
synthesize a class at runtime via `objc_allocateClassPair` /
`class_addMethod`, declare the sx-side `#objc_class` against the same
name, then invoke the DSL form. Skips with a "(not macos)" line on
non-macOS hosts. Snapshots currently lock in the bail diagnostic with
exit=1; the next commit implements the dispatch and the snapshots
flip to the working output (and exit=0).
Checkpoint corrected to flag the prior false claim and reposition 3.0
back at the top of the open list.
CLAUDE.md REJECTED PATTERNS forbids silent default returns where the
"reasonable-looking" value happens to match one common case (s64 = 8
bytes = pointer-sized on the host) and is silently wrong everywhere
else. `resolveType(null) → .s64` was exactly this shape: a top-level
`g_pi := 3.14;` was silently typed as `s64`, producing a wrong-typed
slot and the wrong runtime value.
`resolveType` now takes a non-optional `*const Node`. Twelve callers
were classified:
- Six were already guarded by `if (x.type_annotation != null)` blocks
— the null branch was unreachable. Cleaned up to optional-payload
syntax (`if (cd.type_annotation) |ta|`) so the always-non-null path
is obvious from the type.
- Two (`#objc_call` / `#jni_call` return types) pass `FfiIntrinsicCall.
return_type`, which is `*Node` (not optional) in the AST — the
silent fallback couldn't be reached there either.
- One (top-level `var_decl` at lower.zig:630) DID legitimately receive
null when the user omitted both annotation and initializer typing.
Now mirrors `lowerVarDecl`'s local-scope behavior: explicit
annotation → resolveType; no annotation → `inferExprType` from the
initializer; neither → diagnose with a real error message.
- One (`lowerComptimeGlobal`, fixed in commit 82e7b04 alongside
Phase 1.4) already infers from the comptime expression.
- Two (JNI super-call / JNI method return type) were already
hand-rolled with `if (rt) |t| resolveType(t) else .void`.
Regression at `examples/137-toplevel-var-type-inference.sx`: `g_count
:= 42;` / `g_pi := 3.14;` / `g_flag := true;` at module scope. Pre-fix
`g_pi` got silently typed as `s64` and printed `0` or garbage; now it
prints `3.140000`. 159/159 example tests + chess clean.
The Phase 1.4 serializer left a silent malformed-const case: when the
interp evaluated a `#run` returning a string (or anything with a fat
pointer inside), the data field came in as a `.int` holding a libc
host address. `LLVMConstInt(ptr_type, addr, 1)` happily emitted `i0 0`
in the static const, and the runtime segfaulted on the first read.
Phase 1.4a closes this for string and slice destinations. The signature
of `valueToLLVMConst` now takes the IR `TypeId` (instead of just the
LLVM type) and a borrowed `*Interpreter`. A new helper
`serializeAggregateValue` splits on the IR type:
- `string` / `slice` (fat pointer `{data, len}`): extract `len`, read
that many bytes from the data field's address (via `interp.heapSlice`
for `heap_ptr`, via a new `readHostBytes` for `byte_ptr` / `.int`,
via slice indexing for string literals). Emit the bytes as a private
global byte array using the existing `emitConstStringGlobal`. The
fat-pointer aggregate's data ptr resolves to the byte array's address.
- `struct`: walk the IR field types in lockstep with the value's
fields; recurse with each declared field TypeId. This replaces the
old LLVM-type-walk via `LLVMStructGetTypeAtIndex` which couldn't tell
string-typed fields from generic ptr fields.
- `array`: walk with the element TypeId.
The remaining `.int → ptr` trap (a host address landing in a bare ptr
field outside a fat pointer) now bails loudly with a named diagnostic
identifying it as Phase 1.4a heap-walk follow-up territory. No
practical trigger in-tree, so deferred.
`Interpreter.heapSlice` promoted from package-private to `pub` so
the serializer can read interp-managed heap data.
Regression: `examples/136-comptime-string-global.sx` —
`GREETING :: #run build_greeting();` where `build_greeting` returns
`concat("hello", " world")`. Runtime prints `greeting = 'hello world'`
and `greeting.len = 11`. Pre-1.4a this segfaulted on the first read.
158/158 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
Building on the Option 3 lvalue-borrow rule, the long-lived allocators
in `library/modules/allocators.sx` (GPA, Arena, TrackingAllocator) now
return their state by value instead of via a heap-allocated `*T`. The
caller binds the result to a local; the local IS the allocator state.
`xx local` borrows that storage under Option 3, so the `Allocator`
protocol value's `ctx` points at the local — no heap allocation for
the state struct, no `free` of the state needed.
```sx
gpa := GPA.init(); // GPA (value)
arena := Arena.init(xx gpa, 4096); // Arena (value)
tracker := TrackingAllocator.init(xx gpa); // TrackingAllocator (value)
push Context.{ allocator = xx tracker, data = null } { ... }
```
Why by-value:
- One fewer `libc_malloc` per allocator instance.
- No state-struct leak. The local is reclaimed at scope exit; `deinit`
only handles downstream resources (chunks, etc.) — not its own struct.
- Owning structs can embed allocators as value fields directly.
Callsite changes:
- `library/modules/ui/pipeline.sx`: `arena_a: Arena;` / `arena_b:
Arena;` (was `*Arena;`). The `build_arena: *Arena` local takes
`@self.arena_a` / `@self.arena_b`.
- `examples/126-xx-recover-then-dispatch.sx`: `recovered == @gpa`
instead of `recovered == gpa` (gpa is a value now).
- `examples/135-xx-lvalue-borrows.sx`: drop the `tracker_ptr.*`
deref — `init` already returns the value.
- `examples/50-smoke.sx`: Arena alloc counts dropped by 1 (no
state-struct allocation). Comments + snapshot updated.
`Arena.deinit` drops the trailing `parent.dealloc(xx a)` — the
caller's local owns the storage.
FFI IR snapshots regenerated to reflect the new signatures:
`@GPA.init` returns `i64` (was `ptr`); `@Arena.init` and
`@TrackingAllocator.init` use sret returns (was `ptr`).
CLAUDE.md "Allocator construction" rule rewritten around the
by-value convention. The forbidden caller-provides-storage and
redundant-pointer-rename patterns are still forbidden but for the
right reasons now (verbose, fragile) rather than as a workaround
for the old `init() -> *T` shape.
157/157 example tests pass; chess clean on macOS, iOS sim, and
Android via `tools/verify-step.sh`.
`xx <struct-typed local>` used to heap-copy the value through context.allocator.
The protocol value's `ctx` pointed at the heap copy; the original local was
left behind, untouched. Mutations through the protocol never reached the
original, and direct reads of the original never saw protocol mutations.
Two-fork bug, silent, easy to write by mistake.
New rule (Option 3 in the discussion):
- `xx <lvalue>` — identifier, field access, index expression, deref —
borrows the operand's storage. No heap copy, no `free` needed.
- `xx <rvalue>` — struct literal, function-call result, arithmetic, etc. —
heap-copies through context.allocator. Unchanged from today.
- `xx @ptr` and `xx <pointer-typed value>` — borrows the pointee. Unchanged.
Single switch in `buildProtocolErasure` ([lower.zig:10334](src/ir/lower.zig#L10334))
gated by a new `isLvalueExpr` helper ([lower.zig:10322](src/ir/lower.zig#L10322)).
Struct-typed operand: if the AST shape is identifier/field/index/deref,
emit `lowerExprAsPtr(operand_node)` and skip the heap-copy; otherwise
keep the alloca-store-heap_copy path.
specs.md §3 ownership table extended to three rows (rvalue, lvalue,
pointer) with examples and rationale per row.
Regressions:
- `examples/130-xx-value-routes-through-context-allocator.sx` — the
Phase 1.1 witness for heap-copy-via-context-allocator. Previous shape
(`xx <local-value>`) is now a borrow under Option 3 and no longer
exercises the heap-copy path. Rewritten to use a struct literal
(`xx ByValue.{...}`) which still heap-copies through context.allocator
— Tracer.count = 1 as before.
- `examples/135-xx-lvalue-borrows.sx` — new test. Dereferences a
TrackingAllocator into a stack value, does `xx tracker` inside a
push Context, and asserts alloc_count/dealloc_count on the LOCAL go
up. Under old semantics this would have stayed at 0 (heap copy got
the increments, local stayed stale).
157/157 example tests pass; chess clean on macOS / iOS sim / Android
(`tools/verify-step.sh` ran green immediately before this work).
`valueToLLVMConst` in emit_llvm previously handled int / float / boolean
and collapsed everything else into `LLVMConstNull(ty)`. A `#run` returning
a struct, string, function pointer, or anything aggregate produced a
zero-initialized global silently — the comptime result was computed by
the interp, then thrown away when emit_llvm couldn't represent it.
Replaced with a real walk:
- int / float / boolean — as before.
- null_val — `LLVMConstNull`.
- void_val / undef — `LLVMGetUndef`.
- func_ref — `func_map` lookup (already populated for the implicit-Context
static initializer of `__sx_default_context`).
- string — `emitConstStringGlobal`, returns a pointer to the byte array.
- aggregate — recurse field-by-field. Struct: walk
`LLVMStructGetTypeAtIndex` and emit `LLVMConstNamedStruct`. Array:
walk `LLVMGetElementType` and emit `LLVMConstArray2`.
The remaining variants (heap_ptr, byte_ptr, slot_ptr, closure, type_tag)
bail loudly with a `std.debug.print` carrying the global name — per
CLAUDE.md REJECTED PATTERNS, no more silent unimplemented arms. heap_ptr
serialization requires threading the IR `TypeId` so the heap content can
be walked recursively; deferred to Phase 1.4a alongside cycle detection.
The call site at emit_llvm.zig:676 now passes `global.name` so the
diagnostic locates the offending `#run` binding.
Type-inference fix at the binding site: `NAME :: #run expr;` with no
annotation used to default to `s64` via `resolveType(null) -> .s64`,
so even a successful Phase 1.4 serialization would emit `{0, 0}` —
the global's destination type was wrong. `lowerComptimeGlobal` now
calls `inferExprType(expr)` when no annotation is given, so the
inferred type matches the comptime function's return type. The
broader `resolveType(null)` fallback is left in place for other
callers — flagged in the MEM checkpoint as a follow-up audit.
Regression: `examples/134-comptime-aggregate-global.sx` exercises
`POINT :: #run make_point()` returning a `Point { x: s32, y: s32 }`.
Both interp (`sx run`) and codegen (`sx build`) now print
`POINT.x = 7 / POINT.y = 13` instead of `0 / 0`. 156/156 example
tests pass; chess unchanged.
Previous version leaned on chess-specific terminology (GlyphCache, render,
frame arena) and made the rule read like a project memo. Replaced with a
generic `LongLived` example, a two-question test for when to apply, and
no incident-specific narrative. The "field name is by convention" line
removes the implicit prescription of `parent_allocator` so projects can
follow their own naming.
Also drops the explicit cross-reference list of existing examples — those
already drift with the code; the principle is enough to recognise the
shape when it appears.
The chess panel-text regression (text vanished after the first move on
macOS) had a single root cause: GlyphCache's entries List, hash table,
and shaped_buf grew through `context.allocator` — which during render
is the per-frame arena. On the next arena reset the backing died, and
subsequent glyph lookups read garbage / wrote into freshly-allocated
view-tree memory.
Fix is shaped as the user proposed: `List(T)`'s mutations take an
optional trailing `alloc: Allocator = context.allocator` argument. No
allocator stored on the container, no init ceremony, every existing
`list.append(item)` callsite keeps working unchanged. Long-lived
owners now write `list.append(item, self.parent_allocator)` and the
arena-leak bug becomes impossible to write accidentally.
Default-arg substitution previously only fired for identifier callees
(`expandCallDefaults` at lower.zig:7978). Extended to the generic
struct-method dispatch path (`list.append(...)` lands here) via a new
`appendDefaultArgs` helper that lowers fd.params[i].default_expr in
the caller's scope and appends to the lowered args slice.
Long-lived owners updated to capture `parent_allocator: Allocator` at
init and use it for every internal growth:
- GlyphCache (the chess bug) — entries, shaped_buf, hash_keys,
hash_vals, atlas bitmap.
- DockInteraction — drops the existing `push Context` workaround in
`ensure_capacity` for the explicit-arg form.
- StateStore — entries list + per-entry data buffer.
- Gles3Gpu, MetalGPU — shaders, buffers, textures (atlas-grow during
render would otherwise leak resources into the frame arena).
Also kept: an operator-precedence fix in pipeline.sx
(`(self.frame_index & 1) == 0` instead of
`self.frame_index & 1 == 0`, which parses as
`self.frame_index & (1 == 0)` = always 0). That was a stealth
single-arena-only bug that masked the GlyphCache one for a long time.
Docs:
- specs.md §11 documents `param: T = expr` default parameter values.
The parser already supported it — formalised in the spec now.
- current/CHECKPOINT-MEM.md logs the change.
- CLAUDE.md REJECTED PATTERNS gains a "Long-lived containers growing
through context.allocator" section with the `parent_allocator`
capture template and the list of existing examples to mirror.
155/155 example tests pass — zero-diff against snapshots since every
existing callsite still resolves to `context.allocator`.
allocViaContext used to fall back to a direct `.heap_alloc` (libc
malloc) when `Context` wasn't registered — i.e. when the program
didn't import std.sx. That was a silent escape hatch: a program could
appear to allocate fine without a `Context`, sidestepping protocol
dispatch entirely. Same shape as the matchContextAllocCall trap we
removed, just in a different code path.
Now: every site that needs `Context` emits a clear diagnostic when
the type isn't in scope, pointing the user at the required import.
- `allocViaContext`: the three fallback branches (no implicit_ctx, no
Context type, malformed Context struct) all call the new
`diagnoseMissingContext("heap allocation")` and return a
placeholder. Codegen no longer emits libc malloc as the silent
no-import path.
- `lowerPush`: the no-Context branches used to silently drop the
push and just lower the body. Now diagnose first, then lower
(keeping the body's other diagnostics flowing).
- `lowerIdentifier` for "context": used to silently fall through to
`global_names.get("context")` (which would emit an unresolved
identifier with no actionable hint). Now diagnose with the
required-import message.
With every consumer gone, the `.heap_alloc` and `.heap_free` IR ops
are deleted entirely:
- `inst.zig`: drop the Op variants.
- `interp.zig`: drop the execInst arms.
- `emit_llvm.zig`: drop the arms (the `getOrDeclareMalloc/Free`
helpers stay — they're still used by the foreign-decl path for
user-level `malloc`/`free` foreign bindings).
- `print.zig`: drop the printers + the isVoidOp arm.
- `emit_llvm.test.zig`: drop the unit test (op no longer exists).
155/155 example tests pass. Unit tests green. Chess green on macOS /
iOS sim / Android. A program that doesn't import std.sx and tries to
use `context.allocator.alloc` or `push Context.{}` or the `context`
identifier now gets a real error:
error: heap allocation requires the Context type — add
`#import "modules/std.sx";` (or a module that imports it)
Closes the last silent allocation-protocol escape.
The closure trampoline's env-buffer heap-copy in `lowerLambda` used to
call `.heap_alloc` directly (libc malloc, no protocol). Now it routes
through `allocViaContext` like every other compiler-internal alloc,
so a closure created inside `push Context.{ allocator = ... }` honors
the installed allocator — trackers count the env, arenas absorb it,
custom allocators see it. Closes the last `.heap_alloc` shortcut for
sx-internal allocations.
One ordering subtlety fixed alongside: the deferred restore of
`current_ctx_ref` at lowerLambda exit fired AFTER the env-and-closure
build section, so `allocViaContext` was reading `Ref.fromIndex(0)`
(the lambda's own ctx param, only valid inside the lambda body) when
emitting the alloc in the CALLER's scope. Without the explicit
restore, the env_heap dispatch silently routed through the default
context — the captured tracker never saw it. Fixed by restoring
`current_ctx_ref` right after `self.builder.func = saved_func`, before
the env build.
Regression test: `examples/133-closure-env-routes-through-context-allocator.sx`
mirrors the 130-xx-value pattern — install a Tracer via `push Context`,
create a capturing closure inside, assert `Tracer.count = 1`. Without
the fix the count is 0 (env goes through default context). Verified
by stashing the lower.zig change and re-running.
Bonus: `examples/50-smoke.sx` "closure-gpa" output flips from
`allocs=-1` to `allocs=0`. The old `-1` was the bug's signature —
the test manually `dealloc`'d the env after the closure ran, but the
GPA had never seen the matching alloc, so its counter went negative.
With Phase 1.3 the alloc/dealloc balance at 0. Snapshot regen.
155/155 example tests pass (133 new + 50-smoke regen). Chess green on
macOS / iOS sim / Android.