Commit Graph

327 Commits

Author SHA1 Message Date
agra
bb6eca6b91 ffi M5.A.next.1c.A: pack type rep — lock in parser rejection
Next slice of the variadic heterogeneous type packs (`..$args`)
feature: type-system representation. Per the FFI cadence rule, this
commit locks in the parser-rejection behavior so the next commit's
type-rep extension surfaces as a behavior shift.

examples/154-pack-type-rep.sx uses `..$args` inside a `Closure(...)`
type expression — the pack-shape spelling used by impl headers like
`impl Into(Block) for Closure(..$args) -> $R`. Today's parser
recognizes `..$args` only at the parameter-list site (1b);
`parseTypeExpr`'s `Closure(...)` arm calls `parseTypeExpr` per
position and hits "expected type name" at the `..` token. Snapshot
captures the rejection at line 18, column 26.

Next commit (1c.B):
- Parser: `parseTypeExpr` Closure arm accepts `..$args` as the
  trailing pack marker. AST gets a `pack_name: ?[]const u8` (or
  equivalent) field on `ClosureTypeExpr`.
- types.zig: `FunctionInfo` / `ClosureInfo` gain `pack_start: ?u32`
  so the pack shape is distinct from any concrete arity in the
  type table. Hash/eql updated.
- type_bridge: `resolveClosureType` threads pack_start through.
- 154 flips green.

192/192 example tests + `zig build test` green.
2026-05-27 12:09:04 +03:00
agra
a51fe26cbf ffi M5.A.next.1b: parser accepts ..$args as a variadic-pack param
Extends parseParams in src/parser.zig:1558 to recognize a leading
`..` before the optional `$` sigil and the parameter name. The
old `args: ..T` form (variadic marker after the colon) still
works — both paths set the same `is_variadic` flag.

A pack declaration `..$args` parses as:
- `is_variadic = true` (from the leading `..`)
- `is_comptime = true` (from the `$` sigil)
- `type_expr = inferred_type` (no `:` annotation)

The no-colon branch now propagates `is_variadic` and `is_comptime`
onto the Param struct so later slices (type rep, impl matching,
monomorphisation) can read both flags from the parsed AST without
re-deriving from token sequence.

`examples/150-pack-parse.sx` flips from rejecting-with-error to
positive parse smoke. No semantic effect yet — `foo` is declared
but never instantiated.

191/191 example tests + `zig build test` green.
2026-05-27 09:49:41 +03:00
agra
ad82847b76 ffi M5.A.next.1a: variadic heterogeneous type packs — parse lockin
First slice of the `..$args` (variadic heterogeneous type pack)
feature. Locks in the current parser-rejection behavior so the
next commit's parser extension shows up as a behavior shift.

`examples/150-pack-parse.sx` declares `foo :: (..$args) -> s64`.
Today's parser hits `..` where it expects a parameter name
(parseParams in src/parser.zig:1558 only handles `..` inside the
type position after a colon) and emits "expected parameter name".
Expected output captures this rejection.

Per FFI cadence rule, this is the "test fails today, passes after
next commit's parser change" pair.

Pack feature plan saved at
~/.claude/plans/lets-see-options-for-merry-dijkstra.md ("Variadic
heterogeneous type packs" section). Motivates replacing the
hand-rolled per-signature `Into(Block)` impls with one generic
`impl Into(Block) for Closure(..$args) -> $R`; also unlocks
compile-time arity/type errors for `print`/`format`.

191/191 example tests + `zig build test` green.
2026-05-27 09:46:34 +03:00
agra
07f25689ff ffi M5.A revert: drop compiler synthesis, require explicit Into(Block) impls
Reconsidered the M5.A.2 cleanup. The compiler-synthesised trampoline
path was hidden behaviour — a user reading their code couldn't tell
how `xx my_closure : Block` worked without reading lower.zig. That's
exactly the kind of magic sx's design has been pushing against.

New design (strict mode):

1. Stdlib's modules/std/objc_block.sx hand-rolls
   `__block_invoke_void` + `Into(Block) for Closure() -> void` and
   the same pair for `Closure(bool) -> void` (restored from M5.A.2).
   These are readable reference implementations of the bridge ABI.

2. The compiler intercept fires NO synthesis — instead, when
   `tryUserConversion` can't find a reachable `Into(Block)` impl for
   the closure's signature, it emits a focused diagnostic:
     "no `Into(Block) for <Closure-sig>` impl — add a per-signature
      `__block_invoke_<sig>` trampoline + Into impl alongside the
      existing ones in modules/std/objc_block.sx, or declare it in
      your own code"

3. Per-signature declarations live in stdlib (for common signatures)
   or in user code (for app-specific ones). 96-objc-block-multi-arg
   now demonstrates the user-side pattern in-file — it declares its
   own `__block_invoke_void_s32_p` + `Into(Block) for Closure(s32,
   *void) -> void` impl alongside its main().

Net effect:
- Every block bridge is source-visible. No hidden compiler magic.
- Users see exactly how the Apple ABI shape is constructed in sx
  source — stdlib serves as the reference implementation.
- Compiler enforces the discipline: missing impl → clear diagnostic
  pointing at the template.
- Coverage for arbitrary signatures requires conscious user opt-in,
  not silent fallthrough.

Removed from lower.zig: `tryClosureToBlockConversion`,
`emitBlockInvokeTrampoline`, `mangleClosureSigForBlock`,
`mangleTypeForBlock`, and the `block_invoke_trampolines` dedup
state field. Net: the synthesis machinery is gone; only the
detection helper `isClosureToBlockCast` remains, used by the
diagnostic.

190/190 example tests pass; chess on iOS-sim green.
2026-05-27 00:34:26 +03:00
agra
26329fe7ba ffi M5.A.3: multi-arg block smoke test (s32, *void) -> void
A signature the hand-rolled stdlib never covered: `Closure(s32, *void) -> void`.
Pre-M5.A this code wouldn't compile (no `Into(Block) for Closure(s32, *void) -> void`
declaration); post-M5.A the compiler emits `__block_invoke_v_i_p` on
demand and the call site goes through it.

The test uses two-arg side-effect capture (globals `g_sum`, `g_tag`)
to verify both args reached the closure body. Confirms the
trampoline's calling convention forwards
`(__sx_default_context, sx_env, arg0, arg1)` correctly through to
the closure's underlying fn.

Note: return-value signatures (e.g. `Closure(s32) -> s32`) are
recognised by the trampoline emitter — `cinfo.ret` flows through
to `beginFunction`'s return slot — but exercising them requires
closure-return-type inference that the test runner stumbled on
during authoring (`(n: s32) => { return n+1; }` infers void). The
void-returning shape is the more common Cocoa pattern (animation
bodies, dispatch_async, completion handlers); return-value
signatures land properly once the closure inference catches up
(orthogonal to M5.A).

190/190 example tests pass.
2026-05-27 00:26:30 +03:00
agra
556e4e12ea ffi M5.A.2: drop hand-rolled __block_invoke_* impls + Into(Block) per-sig boilerplate
The compiler-synthesised trampoline path (previous commit) covers
every closure signature on demand; the hand-rolled stdlib impls
were only for two specific shapes (`Closure() -> void`,
`Closure(bool) -> void`) and are now strictly redundant.

Kept: the `Block` struct, `BlockDescriptor`, the
`_NSConcreteStackBlock` extern decl, and the shared
`__sx_block_descriptor` global. The compiler-emitted code
references all four; users still need to `#import
"modules/std/objc_block.sx";` to bring them into the module.

Removed: `__block_invoke_void`, `__block_invoke_bool`, and both
`impl Into(Block) for Closure(...) -> void` blocks. Replaced with
a comment block explaining how the compiler now handles the cast.

After this commit, `xx my_closure : Block` works for ANY closure
signature with no per-signature stdlib boilerplate. 189/189
example tests pass; chess on iOS-sim green.
2026-05-27 00:24:36 +03:00
agra
22c087ff35 ffi M5.A: compiler-synthesised __block_invoke_<sig> trampolines
`xx closure : Block` casts now bypass the user-space Into(Block)
protocol path entirely. The compiler intercepts in
`tryUserConversion` BEFORE the Into lookup, detects when src is
`Closure(...)` and dst is `Block`, and emits:

1. A C-ABI trampoline `__block_invoke_<sig>` (deduped per closure
   signature via `block_invoke_trampolines` map). Body matches the
   existing hand-rolled `__block_invoke_void` exactly: load
   block_self struct, extract sx_env (field 5) + sx_fn (field 6),
   call sx_fn(__sx_default_context, sx_env, ...user_args), return.

2. Inline Block-struct construction at the cast site:
   `Block { isa = &_NSConcreteStackBlock, flags=0, reserved=0,
            invoke = &__block_invoke_<sig>,
            descriptor = &__sx_block_descriptor,
            sx_env = closure.env, sx_fn = closure.fn_ptr }`

Signature mangling: compact codes — `v` void, `b` bool, `i` s32,
`q` s64, `f` f32, `d` f64, `c/C/s/S/I/Q` for other ints, `p` for
pointers/aggregates that lower to a machine word. Return first,
then params underscore-joined. `Closure() -> void` mangles to `v`;
`Closure(bool) -> void` mangles to `v_b`.

Loud failures at the cast site:
- `Block` struct missing → "requires #import \"modules/std/objc_block.sx\";"
- `_NSConcreteStackBlock` extern missing → same diagnostic.
- `__sx_block_descriptor` global missing → same.
- `__sx_default_context` missing inside the trampoline emitter →
  compiler-bug diagnostic (the scan pass should always register it).

The existing hand-rolled stdlib impls (`__block_invoke_void`,
`__block_invoke_bool`, the two `Into(Block) for Closure(...)`
impls) are now redundant — the compiler-synthesised trampoline
takes over via the intercept. Next commit (M5.A.2) removes them.

95-objc-block-noop continues to pass; IR shows `__block_invoke_v`
(the synthesised name) replacing the hand-rolled
`__block_invoke_void` at the cast site. 189/189 example tests
pass; chess on iOS-sim green.
2026-05-27 00:23:19 +03:00
agra
e5ba06b66c checkpoint: M4 complete (M4.0 + M4.A + M4.B all landed)
12 commits this session shipped the entire M4 milestone. sx-defined
Obj-C classes now honor `context.allocator` end-to-end, route through
NSObject's retain/release at the source-language level, and emit
correct ARC ops in property setters/getters/dealloc per the Apple
ABI contract.

189/189 example tests pass; chess on iOS-sim green throughout.
Ready for M5 (closure↔block bridge) or M1.1.b (Class(T) phantom
typing) next session.
2026-05-26 23:10:41 +03:00
agra
fcbd7a4235 ffi M4.B dealloc: release strong/copy property ivars + destroyWeak weak
emitObjcDefinedClassDeallocImp now walks the class's #property fields
BEFORE freeing the state struct. For each:

- assign  → no-op (primitives, no ARC traffic).
- strong  → val = load field; objc_release(val).
- copy    → same as strong (the stored value is a +1 retained copy
            produced by the setter's [val copy]; we release it here).
- weak    → objc_destroyWeak(&field) — unregisters the slot from
            libobjc's side-table so the runtime stops tracking it.

Order matters: property releases happen BEFORE freeing the state
struct (which would invalidate the pointers we need to read), which
happens BEFORE [super dealloc] (which eventually frees the Obj-C
instance's own memory). The full sequence is now:

  %state    = object_getIvar(self, __sx_state_ivar)
  // M4.B (this commit):
  for each strong/copy property P:
      val = load struct_gep(state, P.idx); objc_release(val)
  for each weak property P:
      objc_destroyWeak(struct_gep(state, P.idx))
  // M4.0c (already shipped):
  allocator = load struct_gep(state, 0)
  allocator.dealloc(state)
  object_setIvar(self, ivar, null)
  // M1.2 A.6:
  [super dealloc]   // → objc_msgSendSuper2

ffi-objc-arc-02-strong-property now passes: child held by parent's
strong property gets released when parent deallocates, refcount → 0,
child deallocates, both states freed via tracker. Balanced 2/2.

189/189 example tests pass; chess on iOS-sim green. M4 complete.
2026-05-26 23:10:00 +03:00
agra
c88a293cf4 ffi M4.B getter: weak property reads through objc_loadWeakRetained
emitObjcDefinedPropertyGetter dispatches on objcPropertyKind. The
strong/copy/assign paths keep their bare load. The weak path:

  retained     = objc_loadWeakRetained(field_addr)
  autoreleased = objc_autorelease(retained)
  return autoreleased

`objc_loadWeakRetained` does the race-safe upgrade via libobjc's
side-table: if the target has deinitialized (or is mid-dealloc on
another thread), returns null; otherwise returns the target with
refcount bumped (+1 retained, transferred to caller).

`objc_autorelease` drops the +1 into the current pool so the
caller doesn't need to manually balance — matches Apple's auto-nil
weak-getter contract.

The bare-load weak path (still in place pre-M4.B-getter) worked
for the single-threaded test scenario because the runtime nils the
slot before the load happens. The load-retained version covers the
multi-threaded "between load and use, target deinit's" race that
silent bare-load can't.

189/189 example tests pass; chess on iOS-sim green.
2026-05-26 23:04:00 +03:00
agra
f4faef97dd ffi M4.B setter: emit ARC ops in sx-defined property setters
emitObjcDefinedPropertySetter now dispatches on objcPropertyKind to
emit the right runtime ops per Apple's ARC contract:

- assign  → bare store (primitives, explicitly opted-out object slots).
- strong  → load old; objc_retain(new); store new; objc_release(old).
            Apple's runtime treats release(NULL) as a safe no-op, so
            no explicit null-check on the old value.
- weak    → objc_storeWeak(field_addr, val) — handles first-store
            (init) and re-store (destroy + init) atomically. Registers
            the slot with libobjc's side-table; the runtime auto-nils
            it when the target deallocates.
- copy    → [val copy] (sends `copy` selector — returns retained per
            the NSCopying contract); load old; store the copied
            instance; release old.

Side-effect on the weak path: even with the bare-load getter still in
place (loaded directly from the slot), weak reads work because Apple's
runtime side-table-nils the slot at target dealloc. The getter
improvement via objc_loadWeakRetained is the next commit and is
needed for race-safe reads (between load and use, the target could
deinit on another thread); for the single-threaded test scenarios
the bare load is sufficient.

ffi-objc-arc-02-strong-property advances from "child dealloc'd at
midpoint" to "unbalanced; alloc=2 dealloc=1" — strong setter now
retains, but the M4.B-dealloc cleanup hasn't landed so the child
held by the property isn't released when the parent deallocates.
Final commit (M4.B dealloc) closes the loop.

ffi-objc-arc-03-weak-property turns fully green: storeWeak +
auto-nil side-table do the work.

189/189 example tests pass; chess on iOS-sim green.
2026-05-26 23:02:08 +03:00
agra
5c1d00a877 ffi M4.B helpers: objcPropertyKind + ARC runtime decls + xfail tests
Three pieces, no behavior change yet:

1. `ObjcPropertyKind` enum (strong/weak/copy/assign) + `objcPropertyKind`
   helper in lower.zig. Reads `field.property_modifiers`, applies the
   default rule (`*<ObjC-class>` → strong; primitives → assign), and
   emits loud diagnostics for the silent-error budget:
   - unknown modifier name (typo) → "expected one of: strong, weak, copy, ..."
   - conflicting modifiers (e.g. `strong,weak`) → "mutually exclusive"
   - `weak` on non-object slot → "requires a pointer-to-Obj-C-class type"
   - `copy` on non-object slot → same
   - `strong` (default or explicit) on `*void` → "ambiguous: specify
     #property(strong|weak|copy|assign) explicitly"
   Called from `emitObjcDefinedClassPropertyImps` for validation; the
   returned kind isn't wired into setter/getter/dealloc yet — that's
   the next three commits.

2. `ensureArcRuntimeDecls` lazily declares libobjc's ARC helpers:
   objc_retain, objc_release, objc_storeWeak, objc_loadWeakRetained,
   objc_initWeak, objc_destroyWeak. Uses the existing
   `ensureCRuntimeDecl` pattern; idempotent.

3. Fix existing NSObject method names in std/objc.sx — `isEqual_`,
   `isKindOfClass_`, `respondsToSelector_` had trailing underscores
   that the selector mangling turned into double-colon selectors
   (`isEqual::`). Removed the trailing underscore so the selectors
   come out as `isEqual:`, `isKindOfClass:`, `respondsToSelector:`
   as Apple's runtime expects.

4. Two xfail regression tests:
   - ffi-objc-arc-02-strong-property: assigns child to parent's strong
     property, releases the original child reference. Midpoint check:
     child's dealloc should NOT have fired (strong setter retained).
     Pre-M4.B-setter: child dealloc fires immediately → "FAIL: child
     dealloc'd at midpoint" snapshot. Exit code 1.
   - ffi-objc-arc-03-weak-property: assigns target to holder's weak
     property, releases target. Reads holder.target → should be null
     (auto-niled). Pre-M4.B-getter/setter: reads stale pointer →
     "FAIL: weak property didn't auto-nil" snapshot.

These will turn green as M4.B setter (commit 2), getter (commit 3),
and dealloc-cleanup (commit 4) land. Each subsequent commit updates
the snapshot to reflect the now-passing output.

189/189 example tests pass; chess on iOS-sim green.
2026-05-26 22:58:30 +03:00
agra
8c3831acd2 test: M4.0 allocator-threading regression coverage
Two regression tests pinning down the silent-error surface in M4.0:

ffi-objc-arc-00 — single sx-defined-class instance round-trips
through a TrackingAllocator-wrapped GPA. Captures alloc/dealloc
deltas around the lifecycle, verifies (+1, +1). Pre-M4.0 the +alloc
IMP used libc malloc and -dealloc used libc free; tracker would
have observed (+0, +0) and missed the leak silently.

ffi-objc-arc-00b — three instances alloc'd and released. Catches
bugs where:
- the captured allocator becomes shared (one global slot vs
  per-instance);
- alloc captures the wrong allocator on the 2nd+ instance;
- dealloc reads garbage if state[0] is overwritten between
  instances.

Both tests are macos-only (libobjc + NSObject must be present at
runtime). Both wrap the lifecycle in `push Context.{ allocator =
xx tracker }` so the threading path is exercised.

Important authoring note: `print` inside the push-block also routes
through tracker (string formatting allocs), polluting the leak
delta. Tests capture before/after counts WITHOUT any prints between
alloc and release, then verify the BALANCE — every alloc paired
with a dealloc — rather than absolute counts. Discovered while
writing 00: an initial naive "leak_count() == 0" assertion failed
not because M4.0 was broken but because print's string allocs
weren't freed at scope exit.

187/187 example tests pass.
2026-05-26 22:46:56 +03:00
agra
afa9d4aa46 checkpoint: M4.0 + M4.A complete; M4.B remaining
Snapshot of FFI progress mid-M4. Allocator-aware sx-defined-class
lifecycle is done end-to-end (state struct + +alloc + -dealloc).
Stdlib NSObject + autoreleasepool helper landed; defer-release
pattern works at user code. Property ARC ops (M4.B) is the next
slice.

185/185 example tests pass; chess on iOS-sim regression-verified
at every M4 sub-commit.
2026-05-26 22:39:52 +03:00
agra
29404afdee ffi M4.A: stdlib NSObject + autoreleasepool helper + extends rooting
Declare `NSObject` in std/objc.sx as `#foreign #objc_class("NSObject")`
with the canonical instance + class-method surface every Obj-C class
inherits: `retain`/`release`/`autorelease`/`new`/`alloc`/`init`/
`description`/`hash`/`isEqual_`/`isKindOfClass_`/`respondsToSelector_`/
`class`. Root the foreign-class hierarchy in uikit.sx at NSObject by
adding `#extends NSObject;` to every previously-unrooted declaration
(NSValue, NSNumber, NSDictionary, NSSet, NSNotification, NSBundle,
NSNotificationCenter, NSRunLoop, CADisplayLink, CALayer, EAGLContext,
UIScreen, UIResponder) plus deeper chain fixes (NSMutableDictionary
extends NSDictionary; UIWindow extends UIView; UIViewController
extends UIResponder). After this, M2.3's extends-chain walk finds
`retain`/`release` on any UIKit-typed value:

  view := UIView.alloc().init();
  defer view.release();        // canonical sx idiom — no language magic

Plus `autoreleasepool(body: Closure())` stdlib helper that wraps
`body` in `objc_autoreleasePoolPush` / `defer objc_autoreleasePoolPop`.
Required for Foundation factory returns; closure-call frame is real
cost so hot loops should inline the push/defer-pop pattern manually.

Smoke test `ffi-objc-arc-01-autoreleasepool.sx` exercises both
patterns; refresh of two IR snapshots picks up the new stdlib decls
appearing in test outputs that include `modules/std/objc.sx`.

185/185 example tests pass; chess on iOS-sim green.
2026-05-26 22:38:32 +03:00
agra
92ac51445d ffi M4.0c: -dealloc frees state through captured __sx_allocator
The synthesized -dealloc IMP now loads `state->__sx_allocator` (the
slot captured at +alloc time by M4.0a + M4.0b) and dispatches
`allocator.dealloc(state)` through the inline-protocol fn-ptr at
slot 2. Old behaviour was `free(state)` — went straight to libc,
ignoring whatever allocator the instance was constructed with.

After this commit, the per-instance allocator design from M1.2 A.5
is finally end-to-end correct:

  push Context.{ allocator = arena } {
      f := SxFoo.alloc();     ← arena.alloc(STATE_SIZE) + capture
      // ... use f ...
  }
  // refcount → 0 ⇒ -dealloc:
  //   load state->__sx_allocator   = arena
  //   arena.dealloc(state)         ← same allocator round-trips

TrackingAllocator now sees the alloc/dealloc pair; the deferred M1.2
A.5 work is done. Closes the loop on M4.0.

The dealloc IMP passes `__sx_default_context` as the implicit __sx_ctx
when invoking the dealloc fn-ptr — the IMP itself has no caller-side
ctx (it's called by Apple's runtime at refcount-zero), and the
default GPA is the right baseline for any nested allocations the
dealloc body might perform.

Each compiler-internal lookup that "can't fail" (Context type,
__sx_default_context global) emits a loud diagnostic instead of
silent fall-through, per the silent-error budget.

184/184 example tests pass; chess on iOS-sim green.
2026-05-26 22:30:48 +03:00
agra
2bbd63d929 ffi M4.0b: thread context.allocator through sx-defined +alloc
Two converging paths now allocate the state struct via the protocol's
allocator instead of raw malloc:

(1) sx-side `Cls.alloc()`: compiler intercepts in `lowerObjcStaticCall`
    when the receiver is a sx-defined `#objc_class` and the method is
    the niladic `alloc`. Emits the inline alloc-and-init sequence
    using the caller's `current_ctx_ref` as the context — so
    `push Context.{ allocator = my_arena } { let f := SxFoo.alloc(); }`
    honors `my_arena` end-to-end. The msgSend dispatch is bypassed
    entirely for this case.

(2) Obj-C-runtime `[Cls alloc]` (Info.plist principal class, NSCoder,
    UIKit reflection): the synthesized `+alloc` IMP shim reads
    `__sx_default_context.allocator` and calls into the same shared
    helper. The IMP has `has_implicit_ctx = false` and runs with no
    caller-side context — the default GPA is the right policy choice
    for "everything Apple's runtime instantiates".

Shared helper `emitObjcDefinedAllocAndInit(fcd, cls_ref, ctx_addr)`
does the work: `class_createInstance` → `ctx.allocator.alloc(STATE_SIZE)`
via the inline-protocol fn-ptr → memset 0 → store allocator at
state[0] (the M4.0a slot, captured for -dealloc's later use) →
`object_setIvar(instance, __sx_state_ivar, state)`. Loud failures
on missing globals via the diagnostics system.

The sx-side interception must explicitly bitcast the
`class_createInstance` result from `*void` to the method's declared
return type (`*<Cls>` or `?*<Cls>`). lowerVarDecl reads the Ref's IR
type when no type annotation is present, and coerceToType is a
no-op for ptr→ptr — without the bitcast, `let f := SxFoo.alloc();`
binds `f` at `*void` and downstream `f.class` / `f.method()` fails
to find anything.

-dealloc still uses `free(state)` (M4.0c rewrites it). 184/184 tests
pass; chess on iOS-sim green.
2026-05-26 22:27:33 +03:00
agra
8d7164f45f ffi M4.0a: prepend __sx_allocator to sx-defined-class state struct
State struct for an sx-defined `#objc_class` now leads with an
Allocator field at index 0 — captured at +alloc time, read by
-dealloc to free the state through the same allocator. User fields
shift to index 1+; the existing by-name lookups in
emitObjcDefinedClassPropertyImps + lookupObjcDefinedStateFieldOnPointer
naturally resolve them at the new indices.

This step is the layout change only; the +alloc IMP still mallocs
(M4.0b will rewrite it to thread context.allocator through), and
-dealloc still uses free() (M4.0c). The field is allocated but
uninitialised; nobody reads it yet.

Storage type comes from `Context.fields[0].ty` via the new
`objcStateAllocatorType` helper — same Allocator value-shape the
implicit context machinery has used all along. If Context isn't
registered (early-init paths), the helper falls back to omitting
the field rather than synthesising a half-broken layout.

IR snapshot for 142-objc-class-method-lowering updated to reflect
the new struct shape and the +24-byte state allocation. Chess on
iOS-sim green; 184/184 example tests pass.
2026-05-26 22:07:56 +03:00
agra
9fbc24a602 ffi uikit cleanup: helpers → UIKitPlatform methods + declarative layerClass
Three threads, one commit because they're entangled:

1. Helper free functions on `*UIKitPlatform` (refresh_safe_insets,
   read_screen_scale, create_gl_context, setup_renderbuffer,
   present_renderbuffer, compute_layer_pixel_size) → methods on the
   `impl Platform for UIKitPlatform` block. IMP-shape trampolines
   (`uikit_keyboard_will_change_frame`, `uikit_scene_will_connect[_ios]`,
   `uikit_gl_view_tick/layout/touches_*`, `uikit_subscribe_keyboard_notifications`)
   also collapse into methods on UIKitPlatform — the
   `(self: *void, _cmd: *void, ...)` form is no longer needed since
   M3 made the #objc_class trampolines compiler-synthesized. Class
   method bodies in SxAppDelegate / SxSceneDelegate / SxGLView /
   SxMetalView now read `if g_uikit_plat == null { return; }
   g_uikit_plat.x();` — no more `xx self, xx 0` casts at every IMP
   call site.

2. Declarative `layerClass` form. SxGLView and SxMetalView promote
   from the M2.1(a) constant-with-runtime-string-lookup workaround
   (`layerClass :: *void = objc_getClass("CAEAGLLayer".ptr);`) to
   the class-method expression-body form
   (`layerClass :: () => CAEAGLLayer.class();`). Type stays `*void`
   until M1.1.b lands `Class(T)` parameterisation; the value side
   already matches the plan. Backing this: foreign-class declarations
   for CAEAGLLayer (extended with `class :: () -> *void;`) and a new
   CAMetalLayer foreign-class declaration alongside it. Both
   `#extends CALayer` so the dispatch chain knows about the parent.

3. Optional-shape idiom pass on uikit.sx. `xx`-as-optional-wrap on
   field assignments (`plat.gl_ctx = xx ctx`, `plat.text_field = xx tf`,
   `plat.display_link = xx link`) dropped — implicit `T → ?T` does
   the right thing. `!` force-unwraps replaced with `if val := opt
   { ... }` safe-narrowing (the keyboard handler, the GL-context
   read in setup/present renderbuffer, the gl_view read in scene
   bootstrap). `orelse` (Zig keyword) that briefly snuck into the
   keyboard handler removed in favour of the `if win := plat.window`
   narrowing pattern. Result: no `xx` casts left on the implicit
   T→?T path; all optional access goes through `if val :=`.

IR snapshots `ffi-objc-call-06-sret-return.ir` and
`ffi-objc-dsl-07-mangling-table.ir` refresh to pick up the new
`object_getIvar` / `object_setIvar` runtime-helper declarations
introduced when M1.2 A.3 made instance-method bodies route field
access through the state ivar.

Chess on iOS-sim green throughout. 184/184 example tests pass.
2026-05-26 16:42:57 +03:00
agra
a923b6f6f0 ffi fix: route foreign-class UFCS arg target_types through extends chain
For UFCS dispatch on foreign-class receivers (`#foreign #objc_class`
aliases), `resolveCallParamTypes` was returning an empty slice — both
`resolveFuncByName(qualified)` and `fn_ast_map.get(qualified)` miss
for `#foreign` methods (they live in `foreign_class_map`, not the
regular fn maps). With `param_types` empty, the per-arg `target_type`
assignment in `lowerCall` was skipped, leaving `self.target_type` as
whatever it held on entry — usually the enclosing function's return
type. Inside a `-> BOOL` method, `xx ptr` then lowered with target
type `i8`: `ptrtoint ptr to i64` → `trunc i64 to i8`, sending the low
byte of the pointer through.

Symptom: chess on iOS-sim crashed in
`-[NSNotificationCenter addObserver:selector:name:object:]` with
`observer = 0xC0` (low byte of the SxAppDelegate receiver) when the
AppDelegate method's first param was renamed to anything other than
`self`. The original session diagnosed it as a `self`-vs-`this`
hardcoding in `lower.zig`, but those hardcoded `"self"` strings are
all on compiler-synthesized parameters (init scopes, JNI stubs,
property IMPs, dealloc IMPs) — not the user-facing #objc_class body
params. The bug was in arg-type resolution.

Fix walks `foreign_class_map` + `findForeignMethodInChain` to recover
the declared param types (skipping the implicit `*Self` for instance
methods). Regression test `examples/issue-0044.sx` exercises the
BOOL-return + foreign-class arg shape; pre-fix the receiver round-trip
prints WRONG, post-fix it prints ok.
2026-05-26 16:42:21 +03:00
agra
f75923af00 uikit: type UIKitPlatform fields properly + handle optional in Obj-C encoding
The UIKitPlatform struct had a string of '*void = null; // UIWindow*'
fields — the type lived in a comment, every callsite had to 'xx'-cast
back to the real type. Migrated to the real foreign-class pointer
types now that M3 declared all the relevant '#objc_class' aliases:

  window:       ?*UIWindow
  root_vc:      ?*UIViewController
  gl_view:      ?*UIView         (SxGLView OR SxMetalView — both extend UIView)
  gl_layer:     ?*CALayer        (CAEAGLLayer OR CAMetalLayer)
  gl_ctx:       ?*EAGLContext
  display_link: ?*CADisplayLink

Each field is wrapped in '?' since the platform may not have set
it yet (gl_ctx is null in metal mode, display_link is null before
the first frame, etc.).

SxSceneDelegate's window getter/setter now take/return '?*UIWindow'
instead of '*void' so calling code doesn't need an xx-cast.

Required fix in objcTypeEncodingFromSignature: '?T' (optional) was
bailing with 'type kind not yet supported'. Apple's runtime treats
nullability as 'pointer may be null' — the wire encoding is the
same as T. Recursive unwrap handles ?*UIView → '@', ?*CADisplayLink
→ '@', etc.

Chess on iOS-sim: board renders, full pipeline intact. 183 tests
+ zig build test green.
2026-05-26 07:51:12 +03:00
agra
d403f56673 uikit: inline three trivial legacy IMP helpers into #objc_class methods
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.
2026-05-26 07:48:15 +03:00
agra
78288b98ac ffi M3.3 + M3.4 + M3.5: SxGLView/SxMetalView migrated; uikit_register_classes deleted
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.
2026-05-26 07:41:07 +03:00
agra
066840d9e0 ffi M3.2: SxSceneDelegate migrated + #implements protocol conformance
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.
2026-05-26 07:37:14 +03:00
agra
66f84f67b8 ffi M3.1 + M1.2 A.3 refactor: self=Obj-C id, self.field via ivar; SxAppDelegate migrated
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.
2026-05-26 07:32:57 +03:00
agra
ea32f8a27a ffi M2.3: #extends method-resolution chaining + Obj-C parent resolution
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.
2026-05-26 01:56:25 +03:00
agra
239e7df27c ffi M2.2 (sx-defined): property getter/setter IMPs
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.
2026-05-26 01:49:31 +03:00
agra
95f13849af ffi M2.2 (first pass): #property directive on foreign-class fields
Adds:
  field: T #property[(modifier, modifier, ...)];

inside #objc_class declarations. For FOREIGN classes (this slice),
'obj.field' and 'obj.field = x' lower as objc_msgSend dispatches —
no struct GEP, no per-field storage on the sx side. The receiver
is opaque and the Obj-C runtime owns the data.

Selector mangling (Apple convention):
  getter: <fieldName>            (e.g. 'count')
  setter: set<FieldName>:        (e.g. 'setBackgroundColor:')

So:
  view.backgroundColor          → [view backgroundColor]
  view.backgroundColor = red    → [view setBackgroundColor:red]

Plumbing:
- New token hash_property + lexer entry + LSP keyword classification.
- ForeignFieldDecl gains 'is_property' + 'property_modifiers' slice;
  the parser captures both. Modifiers are recorded verbatim (strong,
  weak, copy, readonly, getter("name"), ...) — semantic interpretation
  lands with M4.2 ARC wiring.
- lowerFieldAccess: lookupObjcPropertyOnPointer() detects the case
  before the auto-deref / struct-GEP path and dispatches via
  lowerObjcPropertyGetter (objc_msg_send).
- lowerAssignment: same check on the field_access LHS routes to
  lowerObjcPropertySetter (objc_msg_send with set<Field>:).
- inferExprType: 'obj.field' returns the property's declared type
  so chained access / coerced assignment work.

151-objc-property-foreign.sx round-trips:
  inst.tag        → [inst tag]       → reads g_probe_tag → 0
  inst.tag = 42   → [inst setTag:42] → writes g_probe_tag
  inst.tag = -7   → ditto
  Final: 0 -> 42 -> -7  (real Obj-C runtime dispatch).

DEFERRED for M2.2 (later passes):
- Sx-defined property IMPs (synthesized getter/setter trampolines
  reading/writing the state struct).
- Modifier-driven setter behavior: readonly (compile error on
  write), copy (deep-copy), weak (objc_storeWeak), strong/assign
  (Month 4.2 ARC ops).
- getter("name") / setter("name:") selector overrides.

181 example tests pass (+1). zig build test green.
2026-05-26 01:45:21 +03:00
agra
d6ef691e42 ffi M2.1(a): class-level constants 'name :: Type = expr;'
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.
2026-05-25 23:43:46 +03:00
agra
c39c8e15eb ffi M2.1(b): class methods on sx-defined #objc_class
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.
2026-05-25 23:40:51 +03:00
agra
0ac5ba2ccd ffi M1.3: obj.class accessor on Obj-C-class pointers
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).
2026-05-25 23:33:52 +03:00
agra
51277afadf ffi M1.2 A.7: open the dispatch gate — sx-defined class methods callable
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.
2026-05-25 23:29:55 +03:00
agra
c107aa4e21 ffi M1.2 A.6: synthesized -dealloc IMP + [super dealloc] chain
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.
2026-05-25 23:25:13 +03:00
agra
a1736f3213 ffi M1.2 A.5: synthesized +alloc IMP + ensureCRuntimeDecl helper
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.
2026-05-25 23:17:30 +03:00
agra
87572579b4 ffi M1.2 A.4b.iii: class_addMethod wires IMPs to the Obj-C runtime
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.
2026-05-25 22:58:20 +03:00
agra
c0b338eaa4 ffi M1.2 A.4b.ii: emit C-ABI IMP trampolines (dead code pending class_addMethod)
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.
2026-05-25 22:52:34 +03:00
agra
c2178c062b ffi M1.2 A.4b.i: __sx_state ivar registration
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.
2026-05-25 22:23:59 +03:00
agra
b98a22e3f9 ffi M1.2 A.4: emitObjcDefinedClassInit class-pair registration
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.
2026-05-25 22:14:31 +03:00
agra
659cdc2276 ffi M1.2 A.2c + A.3: eager body lowering + self.field via state struct
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.
2026-05-25 22:08:23 +03:00
agra
ae1072d415 ffi M1.2 A.2b: register sx-defined #objc_class methods + *Self substitution
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.
2026-05-25 21:59:23 +03:00
agra
7b98b3ae78 ffi M1.2 A.2a: objcDefinedStateStructType helper
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.
2026-05-25 21:51:07 +03:00
agra
6cc016cd4f ffi M1.2 A.1: objcTypeEncodingFromSignature helper + encoding table
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.
2026-05-25 21:43:53 +03:00
agra
61a2593020 ffi M1.2 A.0: objc_defined_class_cache + scan-pass registration
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.
2026-05-25 21:37:36 +03:00
agra
d9dbdad3f5 ffi M1.1 (first pass): id / Class / SEL / BOOL type aliases
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).
2026-05-25 21:33:20 +03:00
agra
86c1127c46 ffi M1.0 (3/3): accept '=>' body in '#objc_class' member methods
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.
2026-05-25 21:18:09 +03:00
agra
4a048d34fd ffi M1.0 (2/3, xfail): '=>' body inside '#objc_class' member
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}.
2026-05-25 21:16:32 +03:00
agra
6c95b2ae72 ffi M1.0 (1/3): lock in expression-bodied top-level + struct-method form
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).
2026-05-25 21:15:44 +03:00
agra
bd3033dc5a ffi 3.2 C5: migrate uikit.sx view tree + GL drawables cluster
Final Phase 3.2 cluster. CALayer / CAEAGLLayer / EAGLContext declared
as `#foreign #objc_class` blocks, plus `setContentScaleFactor` added
to UIView and `-layer` now returns `*CALayer` (was opaque `*void`).

Classes declared:

- CALayer      → setOpaque (instance)
- CAEAGLLayer  → setDrawableProperties (instance)
- EAGLContext  → alloc (class), initWithAPI (instance),
                 setCurrentContext (class — takes EAGLContext arg),
                 renderbufferStorage_fromDrawable (instance),
                 presentRenderbuffer (instance)
- UIView       → +setContentScaleFactor (existing decl extended)

The C5 group sits above UIView in the file so the `-layer` return type
`*CALayer` forward-resolves cleanly.

Migration sites in uikit.sx:

- `uikit_create_gl_context` → `EAGLContext.alloc().initWithAPI(api)`
  + `EAGLContext.setCurrentContext(ctx)`.
- `uikit_setup_renderbuffer` → cast `*EAGLContext` and
  `gl_ctx.renderbufferStorage_fromDrawable(target, layer)`.
- `uikit_present_renderbuffer` → same cast + `presentRenderbuffer(target)`.
- Scene-connect bring-up: `gl_layer.setOpaque(1)`,
  `eagl_layer.setDrawableProperties(...)`,
  `gl_view.setContentScaleFactor(scale)`.

One more `objc_getClass(...)` lookup (EAGLContext) retired — the class
slot comes from the declarative binding via `__sx_objc_class_init`.

**Phase 3.2 complete.** Five clusters migrated (C1: Foundation
utility; C2: Notifications + Bundle; C3: RunLoop + display timing;
C4: UIKit chrome; C5: view tree + GL drawables). 8 foreign Cocoa
classes declared; ~30 `#objc_call(T)(...)` sites rewritten to
`recv.method(args)` / `Cls.method(args)`; 6 `objc_getClass`
lookups retired. Sx-defined classes (SxAppDelegate, SxSceneDelegate,
SxGLView, SxMetalView) and a handful of foreign sites that exercise
delegate/protocol surfaces stay on the explicit `#objc_call` form
pending Phase 3.7's class synthesis.

167/167 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
2026-05-25 17:58:55 +03:00
agra
5b4969f9be ffi 3.2 C4: migrate uikit.sx UIKit chrome cluster to #objc_class
Fourth cluster — was blocked on issue-0043, now unblocked by the
preceding `Self`-substitution fix.

Classes declared:

- UIScreen         → mainScreen (class), nativeScale + bounds (instance)
- UIView           → safeAreaInsets, addSubview, layer (all instance)
- UIWindow         → alloc (class), initWithWindowScene, setRootViewController,
                     makeKeyAndVisible, screen (instance)
- UIViewController → alloc (class), init, setView (instance)
- UITextField      → alloc (class), init, becomeFirstResponder,
                     resignFirstResponder (instance)

Migration sites in uikit.sx:

- `show_keyboard` / `hide_keyboard` → `tf.becomeFirstResponder()` /
  `tf.resignFirstResponder()` on a `*UITextField` cast of `text_field`.
- `uikit_refresh_safe_insets` → `gl_view.safeAreaInsets()` on a
  `*UIView` cast of `plat.gl_view`.
- `uikit_read_screen_scale` and GL-context bring-up →
  `UIScreen.mainScreen().nativeScale()`.
- `uikit_keyboard_will_change_frame` → `win.screen().bounds()`.
- `uikit_scene_will_connect_ios` (the function that triggered 0043) →
  `UIWindow.alloc().initWithWindowScene(scene)`,
  `UIViewController.alloc().init()`, `vc.setView(...)`,
  `win.setRootViewController(...)`, `gl_view.layer()`,
  `UITextField.alloc().init()`, `gl_view.addSubview(...)`,
  `win.makeKeyAndVisible()`.

Three `objc_getClass(...)` lookups (UIWindow, UIViewController,
UITextField) are gone — the class slots come from the declarative
bindings via `__sx_objc_class_init`. UIScreen has the same shape.

167/167 example tests; chess clean on macOS / iOS sim / Android via
`tools/verify-step.sh`.
2026-05-25 17:53:11 +03:00
agra
2b717d9b38 ffi: resolve foreign-class member types through Self substitution (issue-0043)
`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.
2026-05-25 17:52:53 +03:00