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.
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.
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).
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.
`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.
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.
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.
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.
The interp's `storeAtRawPtr` used to write 8 bytes from a `.int` /
`.float` Value regardless of the destination's declared width. The
Value tag flattens s8..s64/u*/pointer all to `.int`, so it can't
disambiguate widths on its own — every store risked clobbering up to
7 neighbor bytes if the actual IR type was sub-8.
Fix:
- `inst.Store` gains `val_ty: TypeId` (defaults to `.void` for
backward compat with the LLVM emitter, which doesn't read it).
- `builder.store` captures `getRefType(val)` at emit time.
- `storeAtRawPtr` now takes `val_ty`, looks up
`types.typeSizeBytes(val_ty)`, and writes exactly that many bytes:
`.int` → width bytes of the i64 representation (1..8),
`.float` → 4 (f32 round-trip via @floatCast) or 8,
`.boolean` → 1 (zeros higher width bytes when destination is wider),
`.null_val` → width bytes of zero. Width outside the expected band
bails with a clear diagnostic.
Regression test: `examples/132-comptime-typed-store-widths.sx`. For
every primitive type (u8/u16/u32/u64, s8/s16/s32/s64, bool, f32, f64),
the test:
1. Allocates a 32-byte libc buffer through `context.allocator`.
2. Fills with sentinel byte 0xAA.
3. Writes ONE typed value at offset 8.
4. Sums every byte back.
5. Compares the runtime checksum (LLVM-emitted store, already
correct) against a comptime checksum baked via `#run`.
Mismatch = neighbor clobber. The test exits non-zero with a per-width
"FAIL u8: comptime=X runtime=Y" line so future regressions surface
the offending width.
Also wired:
- Interp's `index_get` gains `.int` / `.byte_ptr` base arms — `buf[i]`
through a raw libc-malloc'd pointer reads one byte at offset i.
Used by the new test's `sum_bytes` loop; previously bailed at
`op=index_get`.
- `emit_llvm`'s comptime-init catch block prints a real diagnostic
instead of swallowing the error and filling the const with zero.
Stale bail state from a previous init is cleared before each call.
154/154 example tests pass (the new test + the existing 153). Chess
still green on macOS / iOS sim / Android.
Passing a default-conv sx function to a `callconv(.c)` fn-pointer slot
(e.g. pthread_create's start routine) used to silently mismatch ABIs:
the C-side caller didn't supply __sx_ctx, so the sx-side body read its
first user param as garbage. The bug surfaced as a SIGSEGV inside
ANativeWindow_setBuffersGeometry on Android during chess bringup.
Now the compiler rejects the coercion outright at the bare-fn name
lookup site:
error: call-convention mismatch: 'sx_handler' is declared with
default sx convention but the target type expects callconv(.c)
Also: `#foreign` declarations without an explicit `callconv` now default
to `.c` instead of `.default`. Every external C symbol is by definition
C-conv; the previous default silently typed `objc_msgSend` (et al.) as
default-conv, so the check would fire on the consumer side when the
user typed a fn-ptr as `callconv(.c)`. With the foreign-default fix,
the existing typed-msgSend casts in `std/objc.sx` and `gpu/metal.sx`
keep type-checking and the rule is "C-conv on both sides or neither."
Caught by the new check (fixed in the same commit):
- `ios_gl_proc` in `platform/uikit.sx` lacked callconv(.c) but was
passed to `load_gl` whose `get_proc` slot expects it.
- `ffi_apply_callback` / `ffi_apply_callback2` in
`examples/ffi-06-callback.sx` had default-conv fn-ptr params but
the C bodies (in the companion .c) are unambiguously C-conv.
Regression test: `examples/131-callconv-mismatch-diagnostic.sx`
locks in the diagnostic shape (sx-conv fn → callconv(.c) slot).
153/153 example tests pass. Chess green on macOS / iOS sim / Android.
Continues the implicit-Context refactor. Bare-fn trampolines, lambda
trampolines, and protocol thunks now carry __sx_ctx at slot 0; call
sites for closures, fn-pointer variables, and method dispatch prepend
the caller's current ctx.
- emit_llvm.zig:1687 call_indirect treats `fp_ctx_slots` leading args
as opaque ptr (the implicit ctx) when the fn-pointer is default-conv
under has_implicit_ctx.
- lower.zig:fnPtrTypeWantsCtx predicate gates the prepend at both
scope-local and global fn-pointer call sites.
- lower.zig:fixupMethodReceiver skips __sx_ctx when probing the
receiver param's type.
- lower.zig:lowerLambda builds closure type from user-visible params
only (skip ctx + env).
- lower.zig:closure(bare_fn) builds closure type from user-visible
params only.
- module.zig: Module.has_implicit_ctx flag mirrors Lowering's switch
so emit_llvm can read it without a back-pointer.
Tests updated:
- 5 ObjC-block/runtime tests get `callconv(.c)` on fn-ptr types
cast from `objc_msgSend` / Block.invoke (C-side calls into sx).
- ffi-06-callback gets `callconv(.c)` on double_it/add_with_ctx —
the registered C-side callbacks.
- 08-types snapshot regen (undefined-init drift from layout shift).
- 11 JNI/ObjC .ir snapshots regen for the ctx-prepended thunk
signatures.
151/152 example tests pass. Remaining failure (05-run) is the
comptime/interp path that requires Step 7 (callWithDefaultContext).
The session-long set of changes that lay the groundwork for the
Jai-literal implicit-Context-parameter refactor. Lots of accumulated
work; the new arrival is the implicit-ctx foundation (steps 1+2 of
the plan in current/CHECKPOINT-MEM.md):
Step 1 — `CAllocator :: struct {}` stateless allocator in
library/modules/allocators.sx, delegating directly to
libc_malloc/libc_free. `ConstantValue` in src/ir/inst.zig gains a
`func_ref: FuncId` leaf so nested aggregates can carry function
pointers (the inline Allocator value's fn-ptr fields). Switch
sites updated in emit_llvm.zig, print.zig, interp.zig.
Step 2 — `emitDefaultContextGlobal` in src/ir/lower.zig synthesises
a static `__sx_default_context` global with a nested-aggregate
init_val pointing at the CAllocator → Allocator thunks. The
second-pass `initVtableGlobals` in emit_llvm.zig is generalised
to handle `.aggregate` init_vals (re-emits after func_map is
populated so func_ref leaves resolve to real symbols).
Also folded in from earlier work this session:
- Phase 1.1: `xx value` heap-copy in `buildProtocolValue` routes
through `context.allocator` via the new `allocViaContext` helper.
- interp.zig: `marshalForeignArg` double-offset bug fixed —
`heapSlice` already adds `hp.offset` to the slice ptr, so the
extra `+ hp.offset` was scribbling memcpy/memset into adjacent
heap state, corrupting `heap.items[0]`. Symptom: `build_format`
at comptime produced zero bytes, all `print` calls failed.
- Lazy lowering: `lazyLowerFunction` now declares foreign-body
functions as extern stubs in the local (comptime) module so
cross-module foreign calls resolve.
- Allocator API: all stdlib allocators on one-line `init() -> *T`
(CAllocator/GPA: libc-backed; Arena/TrackingAllocator: parent-
backed; BufAlloc: embeds state at head of user buffer).
- issues 0038 (transitive #import), 0039 (chess + stdlib migration
fallout), 0040 (generic struct method dot-dispatch), 0041
(pointer types as type-arg), 0042 (alias name resolution) — all
fixed; regression tests in examples/.
- Diagnostic: `emitError` now embeds the lowering's
`current_source_file` and enclosing function in the literal
message; SX_TRACE_UNRESOLVED=1 dumps a Zig stack trace at the
emit site so misattributed spans can't hide where the failure
is.
- tools/verify-step.sh (all-platforms gate) and tools/scratch.sh
(interp/codegen parity tester) added.
Test suite: 152 example tests pass; chess builds + screenshots on
macOS / iOS sim / Android.
Week 7 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
plus the android.sx refactor + three sx-compiler fixes hit along the way
to get chess on Pixel 7 Pro responding to touch end-to-end.
library/modules/platform/bundle.sx now covers the Android APK shape
alongside macOS / iOS-sim / iOS-device. `android_bundle_main` discovers
the SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT / $HOME/Library/Android/sdk),
picks the highest-versioned build-tools + platforms via
`process.run("ls .. | sort -V | tail -1")`, stages
`<apk>.stage/lib/arm64-v8a/<libfoo.so>`, synthesizes
AndroidManifest.xml (NativeActivity vs `#jni_main` Activity branch),
writes each `#jni_main` decl's Java source under
`<stage>/java/<pkg>/<Cls>.java`, runs javac --release 11 + d8 to
produce classes.dex, aapt2-links the unaligned APK, appends lib/ +
classes.dex + each registered asset tree via zip, zipalign + ensure
debug keystore via keytool + apksigner sign.
Compiler-side accessors (src/ir/compiler_hooks.zig + library/modules/compiler.sx):
- is_android predicate.
- set_manifest_path / manifest_path + set_keystore_path / keystore_path.
- jni_main_count / jni_main_foreign_path_at(i) /
jni_main_java_source_at(i) surface the `#jni_main` emissions that
the Zig createApk previously consumed directly.
- main.zig wires manifest_path, keystore_path, and the per-decl
(foreign_path, java_source) parallel slices into BuildConfig before
invoking the post-link callback.
CLI `--apk <path>` keeps working as a transitional alias: it now feeds
bundle_path so the existing auto-`post_link_module = "platform.bundle"`
shim fires the same way as `--bundle`. main.zig no longer calls
target.createApk directly.
Deletions in src/target.zig: createApk, compileJniMainSources,
buildJniMainManifest, buildAndroidManifest, ensureDebugKeystore,
libNameFromSoBasename, plus helpers splitForeignPath / discoverJavac /
discoverAndroidSdk / findHighestSubdir / runProcess / runProcessIn
(~400 lines). git grep returns only the obituary comment.
library/modules/platform/android.sx refactor (chess Android dependency):
- Module-level globals retired (g_app_window, g_egl_*, g_viewport_*,
g_dpi_scale, g_should_stop, g_render_thread*, g_user_main_fn,
g_touch_*) → AndroidPlatform struct fields.
- All sx_android_* helpers take `plat: *AndroidPlatform` as first arg.
Render thread receives plat via pthread_create's arg.
- New `logical_w: f32 = 0.0` field. Consumers set it before init() to
define the design width in points; `recompute_scale` derives
`dpi_scale = pixel_w / logical_w` (or 1.0 if unset). Called on
init / set_viewport / egl_init. drain_touches divides incoming
physical pixel coords by dpi_scale so chess sees logical-space
positions matching its layout. Touch lands on the right squares.
Three sx-compiler bugs hit + fixed along the way:
1. Top-level `inline if OS == .X { decls }` body decls were silently
dropped because scanDecls/lowerDecls had no .if_expr arm. New
`flattenComptimeConditionals` pre-pass in src/imports.zig
(threaded via ComptimeContext from core.zig) hoists matching arms
recursively. Regression at examples/124-inline-if-hoist-toplevel.sx.
2. Parser rejected `#import` / `#framework` inside inline-if bodies
because parseStmt in src/parser.zig only had arms for `#insert`.
Added the missing arms. Regression at
examples/123-inline-if-import-in-body.sx (landed earlier).
3. JNI `Call<T>Method` switches in src/ir/emit_llvm.zig (instance /
nonvirtual / static) were missing `.f32` rows — jfloat returns
(e.g. MotionEvent.getX/getY) fell into the silent-undef else arm.
Chess's sx_android_push_touch(plat, getAction(), getX(), getY())
delivered garbage f32 coords to the touch ring, so taps landed
nowhere recognisable. Added `.f32 => Jni.Call{Static,Nonvirtual,}FloatMethod`
rows to all three switches; lifted unsupported-type detection
from emit_llvm into lowerForeignMethodCall with proper
source-spanned diagnostics (`isJniReturnTypeSupported`). Regressions
at examples/ffi-jni-call-10-jfloat-return.sx,
examples/ffi-jni-class-09-multi-float-args.sx,
examples/ffi-jni-call-11-unsupported-return-diag.sx.
Stale-snapshot drift in tests/expected/ffi-objc-call-03-selector-sharing.ir
and ffi-objc-call-06-sret-return.ir picks up the new BuildOptions
accessor extern decls (is_android, set_manifest_path,
set_keystore_path, jni_main_count, jni_main_foreign_path_at,
jni_main_java_source_at). Verified diff is dead-decl-only.
Chess on Pixel 7 Pro: tap on e2 white pawn -> yellow selection +
green dots on legal e3/e4 targets; tap on e4 -> board updates with
1. e4, "Black to move" + "1. e4" in info panel.
zig build && zig build test && bash tests/run_examples.sh -> 145/145
green. bash tests/cross_compile.sh -> 7/7 green.
Campaign Weeks 3-6 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
land in one push: the bundling pipeline that used to live in
src/target.zig (createBundle, embedFramework, extractEntitlements,
buildInfoPlist, codesign) now lives in
library/modules/platform/bundle.sx and runs in the IR interpreter
after target.link() returns.
New language-side surface:
- library/modules/fs.sx — POSIX libc bindings (open/read/write/close,
mkdir/unlink/rmdir, chmod, rename, access, basename/dirname). Variadic
open() lowers to C's varargs via the new args: ..T form. Direct libc
calls bypass *File method dispatch so they work from the post-link
IR interpreter.
- library/modules/process.sx — popen-based run(cmd) returning
ProcessResult{ exit_code, stdout }, plus env() and find_executable().
- library/modules/std.sx — xml_escape(s) and variadic path_join(parts).
- library/modules/compiler.sx — BuildOptions grows
set_post_link_callback / set_post_link_module / binary_path
accessors; bundle_path/bundle_id/codesign_identity/provisioning_profile
setters + accessors; per-target predicates is_macos/is_ios/
is_ios_device/is_ios_simulator + target_triple; framework_count /
framework_at(i) / framework_path_count / framework_path_at(i);
add_asset_dir(src, dest) + asset_dir_count / src_at / dest_at.
Compiler-side wiring:
- src/ir/compiler_hooks.zig — BuildConfig now carries post_link_callback_fn,
post_link_module, binary_path, bundle_*, target_triple,
target_frameworks, target_framework_paths, asset_dirs. Hook registry
exposes every accessor; getters return "" / 0 for unset fields so
bundle.sx can treat absent values uniformly.
- src/ir/host_ffi.zig (new) — dlsym(RTLD_DEFAULT) + arity-switched cdecl
trampolines so #foreign("c") declarations resolve through the host
libc during #run / post-link interpretation.
- src/ir/interp.zig — callForeign dispatch; build_config pointer
injection so accessor hooks see live state during re-entry.
- src/core.zig — keeps the IR module alive past generateCode; exposes
invokeByName / invokeByFuncId so main.zig can re-enter the
interpreter after linking.
- src/main.zig — wires bundle/codesign/provisioning CLI flags +
target_triple + framework lists into BuildConfig; invokes the
post-link callback (by FuncId or by <module>.bundle_main lookup) once
target.link() returns. When --bundle is set but no callback is
registered, auto-falls-back to post_link_module = "platform.bundle"
so the legacy --bundle CLI keeps working for any program that imports
modules/platform/bundle.sx.
Apple .app bundler (library/modules/platform/bundle.sx):
- Single bundle_main entry covers macOS, iOS simulator, iOS device.
Per-target Info.plist switch keys off is_ios()/is_ios_simulator() —
iOS emits UIDeviceFamily / LSRequiresIPhoneOS /
UIApplicationSceneManifest / DTPlatformName (iPhoneOS or
iPhoneSimulator); macOS emits the minimal CFBundle* set.
- iOS-only steps:
- Provisioning embed: fs.read_file + fs.write_file to
<bundle>/embedded.mobileprovision.
- Framework embed: recursive cp -R per -F search path into
<bundle>/Frameworks/<Name>.framework/ (until fs.sx grows list_dir).
- Entitlements extraction: four process.run calls (security cms -D,
plutil -extract Entitlements xml1, plutil -extract
ApplicationIdentifierPrefix.0, plutil -replace application-identifier)
resolving the wildcard <TEAM>.* -> <TEAM>.<bundle_id>.
- Real codesign with --entitlements when present.
- Asset dirs (add_asset_dir): recursive cp -R src/. into <bundle>/dest/.
Missing src is treated as "nothing to do" so projects can register
add_asset_dir("assets", "assets") unconditionally.
Parser:
- parseStmt() now accepts #import \"path\"; and #framework \"Name\"; as
statement-position tokens. Needed for top-level
inline if OS == .android { #import \"modules/platform/android.sx\"; }
blocks (issue-0042 flatten pass surfaces them); chess's
inline-if-with-#import was rejected at parse time before this fix.
Removals from src/target.zig:
- createBundle, embedFramework, extractEntitlements, buildInfoPlist,
codesign (~210 lines). main.zig no longer calls createBundle after
link(); the sx callback is the single entry point.
Tests / regression markers (all run under sx run host JIT):
- examples/115-post-link-callback.sx — callback registration round-trip.
- examples/116-fs-roundtrip.sx — fs.write_file -> fs.read_file -> exists.
- examples/117-process-roundtrip.sx — process.run + env + find_executable.
- examples/118-macos-bundle.sx — macOS .app via bundle_main callback.
- examples/119-interp-cast-ptr-cmp.sx — cast(T) val under interpreter.
- examples/120-interp-variadic-any.sx — variadic ..Any indexing in IR
interpreter.
- examples/121-ios-sim-bundle.sx — iOS-sim cross-compile + .app with
iOS-shaped Info.plist (added to tests/cross_compile.sh as the
ios-sim tuple).
- examples/122-ios-device-bundle.sx — iOS device cross-compile +
full codesign pipeline (provisioning embed + entitlements
extraction + --entitlements codesign). Manually verified end-to-end:
installed via xcrun devicectl device install app + launched
successfully on iPhone 17 Pro.
- examples/123-inline-if-import-in-body.sx — locks in the parser fix.
zig build && zig build test && bash tests/run_examples.sh => 141 passed,
0 failed; bash tests/cross_compile.sh => 7 passed, 0 failed.
Trailing `args: ..T` on a #foreign declaration now lowers to the C
calling convention's `...` instead of sx-side slice-packing. Drops
the per-arity #foreign-shim workaround for callers of variadic C
APIs (__android_log_print, printf-family, etc.). Closes issue-0043.
- IR: Function.is_variadic on inst.Function; declareFunction drops
the variadic param from the IR signature for foreign+variadic
decls.
- emit_llvm: LLVMFunctionType receives is_var_arg=1 when the flag
is set; call lowering passes extras through unchanged.
- Lowering: packVariadicCallArgs early-outs for foreign+variadic
(no slice-pack); new promoteCVariadicArgs applies C default
argument promotion (bool/s8/s16/u8/u16 -> s32, f32 -> f64) to
extras past the fixed param count.
- Test: examples/ffi-foreign-cvariadic.sx + .c exercise s64/f64/s32
returns through C va_arg over s32/f64/*u8 element types.
134 host + 6 cross tests pass on the WIP-less baseline.
Adds the constructor-invocation arm of the foreign-class DSL:
`SurfaceView.new(ctx)` (where `SurfaceView` is a `#foreign #jni_class`
with `static new :: (ctx: *Context) -> *Self;`) lowers to
`FindClass(env, "android/view/SurfaceView") + GetMethodID(env, cls,
"<init>", "(args)V") + NewObject(env, cls, mid, args...)`. Returns
the fresh jobject.
- inst.zig: `JniMsgSend.is_constructor` flag + `parent_class_path`
re-purposed to carry the class being constructed (alongside its
existing nonvirtual-super-class use). Mutually exclusive with
`is_static` / `is_nonvirtual`.
- lower.zig: `lowerCall.field_access` arm now recognises
`Alias.method(args)` where `Alias` resolves in `foreign_class_map`
and the matching member is `static`. `new` routes to a new
`lowerForeignStaticCall` that derives a `(args)V` JNI descriptor
and emits a `JniMsgSend` with `is_constructor=true`. Non-`new`
static calls report a clear "use #jni_static_call" diagnostic
until that sugar lands.
- emit_llvm.zig: new `NewObject` vtable slot (28) + `emitJniConstructor`
helper expanding the FindClass+GetMethodID+NewObject chain. The
jni_msg_send arm short-circuits to it when `is_constructor` is set.
Smoke `ffi-jni-main-03-ctor.sx` exercises both this slice and the
previous super-dispatch slice in a single `onCreate` body: calls
`super.onCreate(b)` then constructs a `SurfaceView` with the Activity
as Context. IR shows the expected six-stage chain (FindClass+GetMethodID+
CallNonvirtual + FindClass+GetMethodID+NewObject); APK builds clean.
Naming caveat: the Java type `android.content.Context` clashes with
sx stdlib's `Context :: struct {...}` (heap-context). The smoke aliases
it `JContext` — future work could add a path-prefix or `as` rename
form on `#jni_class` to avoid the manual rename.
133 host / 6 cross / zig build test all green.
Inside a `#jni_main` (or any sx-defined `#jni_class`) bodied method,
`super.method(args)` lowers to JNI's nonvirtual dispatch against the
parent class resolved via `#extends` (default `android.app.Activity`).
- lower.zig: tracks `current_foreign_class` + `current_foreign_method`
around each `synthesizeJniMainStub` body; pushes the JNIEnv* arg
onto the lexical `#jni_env` stack so omitted-env JNI calls inside
the body see env without a wrapper. New `lowerSuperCall` handles
the `super.method(args)` receiver pattern: derives parent path,
reuses the enclosing method's signature when names match (the
common `super.<override>(args)` case), or looks up the method on
the parent class declared as `#foreign #jni_class`.
- inst.zig: `JniMsgSend` gains `is_nonvirtual: bool` and
`parent_class_path: ?[]const u8` — the dispatch tag + super class
foreign path. Mutually exclusive with `is_static`.
- emit_llvm.zig: new `CallNonvirtual<T>Method` vtable slots + a
fourth dispatch arm. Resolves the parent jclass via
`FindClass(env, parent_path)` (per-call; caching is follow-up),
then `GetMethodID(env, parent_cls, name, sig)`, then
`CallNonvirtual<T>Method(env, obj, parent_cls, mid, args...)`.
Disassembly on the smoke confirms the chain:
`ldr [env+0x30]` (FindClass) → `ldr [env+0x108]` (GetMethodID) →
`ldr [env+0x2d8]` (CallNonvirtualVoidMethod) with `(env, self,
parent_cls, mid, bundle)`.
132 host / 5 cross / zig build test all green. The slice unblocks
Activity lifecycle overrides (onCreate, onResume, onPause) calling
their required `super.<method>(args)` without raw `#jni_call`
boilerplate.
Deletes the entire NativeActivity / native_app_glue / ALooper stack
that the previous Android entry path was built around:
- `examples/99-android-egl-clear.sx` — the demo of the legacy path.
- `library/modules/platform/android.sx` — `AndroidPlatform.init`,
`run_frame_loop`, `sx_android_bootstrap`, `g_android_app`, plus
the ALooper / AInputEvent / ANativeActivity / AConfiguration
foreign decls that fed them. The JNI helpers (`sx_load_javavm_fn`,
`sx_android_get_env`, `sx_query_safe_insets_jni`, the
`ANATIVEACTIVITY_*` offsets) were tied to the ANativeActivity*
delivered to `android_main` — they're stale now that the OS hands
sx code a Java Activity directly via `onCreate(JNIEnv*, jobject)`.
- `library/vendors/sx_android_jni/sx_android_jni.c` — the input-
handler installer (`sx_android_install_input_handler`), which
poked NDK app-pointer field offsets that no longer exist.
`library/modules/platform/android_jni.sx` (the `Activity`/`Window`/
`View`/`WindowInsets` `#jni_class` registry used for safe-insets
dispatch) survives — it's standalone declarative bindings useful from
any `#jni_main` onCreate body. Docstring updated to drop the
"imported from android.sx" framing.
131 host / 4 cross / zig build test all green. End-to-end smoke APK
still produces the expected JNI-mangled symbol +
SxApp-extends-Activity dex.
External consumers (chess) will need to migrate their entry from the
`AndroidPlatform.run_frame_loop` model to the `#jni_main` model
(Java-side Activity drives lifecycle; onSurfaceChanged / Choreographer
drive frames via JNI callbacks). That migration is downstream work.
`checkRequiredEntryPoints` no longer accepts `android_main` as an
Android entry — `#jni_main #jni_class("...")` is now the sole accepted
path. The diagnostic walks the user through declaring an Activity +
Bundle foreign decl. `isExportedEntryName` drops `android_main` and
`ANativeActivity_onCreate` (both were for the legacy NativeActivity
glue path R.2 stopped linking by default).
Migrates the two cross-compile examples that previously carried an
`android_main` trampoline to a minimal `#jni_main #jni_class(...) { }`
stub:
- `examples/ffi-jni-call-02-void.sx` — tests `#jni_call(void)` lowering
- `examples/ffi-objc-call-10-os-gate.sx` — tests `inline if OS` gating
Both stubs are empty (no `onCreate` body) so they exercise the
entry-point check + R.3's JNI-symbol synthesis pass produces no
symbols. 131 host / 4 cross / zig build test all green.
`examples/99-android-egl-clear.sx` still uses `android_main` and the
AndroidPlatform/native_app_glue stack — its Android-target build now
fails the entry-point check. R.5 removes it along with the rest of
the legacy NativeActivity surface.
Loosens lower.zig's `checkRequiredEntryPoints` to accept either a
`#jni_main #jni_class("...")` decl OR the legacy `android_main`
trampoline. The diagnostic now shows both options when neither is
present.
Updates the slice 2 smoke (`examples/ffi-jni-main-01-emit.sx`) to
express the modern shape — drops `android_main`, declares
`Bundle :: #foreign #jni_class("android/os/Bundle")`, and overrides
`onCreate :: (self: *Self, b: *Bundle) { }` inside the #jni_main class.
The emitted Java now correctly declares `void onCreate(Bundle b)` as
@Override + a matching `private native void sx_onCreate(Bundle b)`
delegate, verified via dexdump.
Full retirement of `android_main` (deleting native_app_glue from the
Android link path, dropping `AndroidPlatform.run_frame_loop`, migrating
chess/EGL demo to the Java-driven lifecycle) is multi-slice rework
and stays as follow-up.
Compilation.lowering_jni_main_decls is populated by lowerToIR (iterating
foreign_class_map for is_main && !is_foreign && runtime==jni_class,
deduped by foreign_path); each entry carries the pre-rendered Java source
from jni_java_emit.emitJavaSource.
createApk extended: when the emission list is non-empty, write each
.java under <stage>/java/<pkg>/<Class>.java, javac --release 11 to
<stage>/classes/, d8 --release --lib <android_jar> --output <stage>
to produce <stage>/classes.dex, then zip the .dex into the unaligned
APK at root level. javac discovery: $JAVA_HOME/bin/javac first, then
`which javac`.
Manifest still hardcodes android.app.NativeActivity (slice 3 wires the
user's class name + android:hasCode="true"), so the bundled .dex is
present but unreferenced at runtime. End-to-end verified via dexdump on
the smoke example's APK — Lco/swipelab/sxjnimain/SxApp; extending
NativeActivity shows up in classes.dex. Non-#jni_main APK builds
(99-android-egl-clear.sx) produce the same shape as before.
Cross-compile tuple added for examples/ffi-jni-main-01-emit.sx
(compile-only — APK exercise is manual).
`#jni_call` collapses to a single surface — env is *always* implicit:
either picked up from the lexically-enclosing `#jni_env(env) { ... }`
block's Ref (cheap, register-resident, no TL touch) or from the
runtime's thread-local slot via `sx_jni_env_tl_get()` (one fn call
per dispatch). The explicit-env shape is gone — chess and the
existing tests migrate cleanly by wrapping their helper-fn bodies
in `#jni_env(env) { ... }`.
The TL slot lives outside the user's IR module so the LLVM ORC JIT
can load object files cleanly without `orc_rt` for TLS support:
library/vendors/sx_jni_runtime/sx_jni_env_tl.c:
static _Thread_local void *sx_jni_env_tl_slot;
void *sx_jni_env_tl_get(void) { return sx_jni_env_tl_slot; }
void sx_jni_env_tl_set(void *env) { sx_jni_env_tl_slot = env; }
Linkage:
- sx-the-compiler links the .c file via build.zig so the JIT
process-symbol generator resolves `sx_jni_env_tl_get`/`_set`.
- AOT targets get the same .c file auto-linked via the lowering
pass: when lower touches the TL externs, it sets
`needs_jni_env_tl_runtime`, and `Compilation.lowerToIR` appends a
synthetic `CImportInfo` to `lowering_extra_c_sources` that
`collectCImportSources` merges with user-written ones.
Lowering-side changes:
- `getJniEnvTlFids` lazily declares the two externs (parallel
to `getSelRegisterNameFid`) and flips `needs_jni_env_tl_runtime`.
- `#jni_env(env) { body }` emits save→set→body→restore via three
`call` ops to the externs; the inner body sees env via the
lexical-direct stack.
- `lowerJniCall` resolves env from `jni_env_stack` (top) or the TL
fallback. The explicit-env branch is gone.
- `jni_env_stack_base` tracks per-fn lexical scope so lazy-lowering
a callee doesn't accidentally see the caller's Ref (Refs are only
valid inside one fn's instruction stream).
Test migration (mechanical):
- ffi-jni-call-{01..09}: each helper fn wraps `#jni_call(...)`
bodies in `#jni_env(env) { ... }`. Returning values pass through
the block as an expression — `#jni_env` now also lowers in
expression position.
Verified:
- zig build test + tests/run_examples.sh: 130/130 green.
- tests/cross_compile.sh: 3/3 green.
- Chess APK rebuilt + reinstalled on Pixel. Board renders with
status-bar clearance + info panel intact; no crashes in logcat.
Safe-insets dispatch through `#jni_env` + lexical-direct now
fully exercised end-to-end on real hardware.
A `#jni_call(void)(target, "name", "sig")` inside a helper fn that
isn't lexically inside a `#jni_env` block should fall back to a
thread-local env read populated by the enclosing `#jni_env(env) {
helper(target); }` scope at runtime. Today the lower-side
"jni_env_stack empty" diagnostic gets queued but compilation
continues to emit_llvm, which fails LLVM verification because env
lowers to `Ref.none` (`i64 undef`).
The make-green follow-up:
- Synthesizes a thread-local `@sx_jni_env_tl` global in emit_llvm.
- `#jni_env(env) { body }` emits a `(load TL → saved, store env → TL,
defer store saved → TL)` sequence so the TL tracks the
innermost-scope env and restores correctly on nesting.
- `lowerJniCall`'s omitted-env path falls back to a TL load when
`jni_env_stack` is empty, instead of erroring.
The lexical-direct optimisation from 2.16b stays the fast path —
helpers in the same fn never touch TL. Only cross-fn callees pay
the (cheap) TL load.
Flip the surface semantics for type-introducer directives: bare
`Foo :: #jni_class("path") { ... }` now means "DEFINE a new Java class
at that path" (sx-side provides the implementations). The `#foreign`
prefix modifier flips it back to "REFERENCE an existing class on the
foreign runtime." Matches how `#foreign` already reads in sx for C
function declarations (`printf :: ... #foreign;`).
Foo :: #foreign #jni_class("path/to/Foo") { ... } // reference
Foo :: #jni_class("path/to/Foo") { ... } // define
Foo :: #jni_main #jni_class("path/to/Foo") { ... } // define + main Activity
Compiler-side changes:
- New `hash_jni_main` lexer token (the launchable-Activity marker).
Existing `hash_foreign` is reused; no new modifier token there.
- `ForeignClassDecl` gains `is_foreign: bool` + `is_main: bool`.
`ForeignMethodDecl` gains `body: ?*Node` so defined-class methods
can carry sx-side implementations (foreign-class methods stay
`;`-terminated).
- Parser learns `tryParseForeignClassPrefix` — peek-and-consume the
modifier tokens, then dispatch to the unchanged
`parseForeignClassDecl` with the flags threaded through.
- Sema rejects two illegal combinations: `#foreign + #jni_main`
(can't be both an external reference and the app's main entry),
and bodied methods on `#foreign` decls (foreign methods are
runtime-provided).
- Lower's foreign-class dispatch errors on non-foreign decls with
a pointer to the runtime-synthesis follow-up; defined-class
codegen (Java class emission, RegisterNatives wiring, manifest
entry generation) lands in a separate session.
Migration:
- `library/modules/platform/android_jni.sx`: all four foreign class
decls (`Activity`, `Window`, `View`, `WindowInsets`) gain `#foreign`.
- `examples/ffi-jni-class-{01..08}*.sx`: every test's `#jni_class` /
`#jni_interface` / `#objc_class` / `#objc_protocol` / `#swift_class`
/ `#swift_struct` / `#swift_protocol` usage gains `#foreign`. All
9 files mechanical perl rename; snapshots unchanged.
Verified locally:
- `zig build test` clean.
- `bash tests/run_examples.sh` 129/129.
- `bash tests/cross_compile.sh` 3/3.
- Chess APK rebuilds, reinstalls, launches on Pixel; safe-area
clearance preserved.
`act.getWindow()` on `act: *Activity` (where `Activity ::
#jni_class("android/app/Activity") { getWindow :: ... }`) should
lower to `#jni_call(*void)(act, "getWindow", "()Ljava/lang/Object;")`
(omitted-env form picking up env from the enclosing `#jni_env`
scope via 2.16b's lexical-direct path). Today's sema reports
"unresolved: 'getWindow'" because foreign-class members aren't
yet wired into the method-resolution path.
The make-green follow-up needs:
- sema: register `ForeignClassDecl.members` so method names
resolve on foreign-class receivers (or suppress the unresolved
fallback for them).
- lower: build a `foreign_class_map` in scan pass; new arm in
`lowerCall`'s method-dispatch site emits a synthetic
`FfiIntrinsicCall { kind: jni_call, args: [target, "name",
"(sig)Ret", method_args...] }` with the descriptor derived via
`jni_descriptor.deriveMethod`.
- type system: `*Activity` resolution path so `inferExprType`
on the receiver returns a known type (likely register foreign
classes as synthetic 0-field structs reusing the struct-type
machinery).
Larger session needed — pausing here at the xfail.
`#jni_call(void)(target, "name", "sig")` (3 args before the first
string literal) should work inside an enclosing `#jni_env(env) { ... }`
scope, picking up the env from the block's value directly. Today's
lowering expects 4+ args and errors with "#jni_call requires env,
target, method name, and signature".
The make-green follow-up adds a lowering-side env stack maintained
across the `#jni_env` body walk, and a disambiguation in
`lowerJniCall` that detects "env omitted" via the position of the
first string-literal arg (method name at index 1 → omitted; at index
2 → explicit env).
`#jni_env(synth_env) { ... }` should parse as a block-scoped env
intrinsic, today the lexer doesn't know the directive and the parser
errors at the `#` token in expression position. The make-green
follow-up adds the `hash_jni_env` lexer token, parser arm in
parsePrimary, AST node, and sema acceptance — body runs as a normal
block, env captured for later. TL push/pop semantics + optional env
in `#jni_call` land in 2.16b.
#jni_interface, #objc_class, #objc_protocol, #swift_class,
#swift_struct, #swift_protocol — each with the same body grammar as
#jni_class. Today the lexer doesn't recognise any of these directives
and the parser errors at the first one (`#jni_interface`). The
make-green follow-up adds the six lexer tokens and refactors
`JniClassDecl` into `ForeignClassDecl` with a `runtime` discriminator
so all seven forms share one AST shape and one parser path.
New `hash_jni_method_descriptor` lexer token + LSP keyword
classification. `JniMethodDecl` gains `desc_override: ?[]const u8`.
parseJniClassDecl accepts an optional `#jni_method_descriptor("...")`
clause between the return type and the terminating `;`, stashing the
literal as the override. Auto-derivation in Phase 2.8 will treat
this as the precedence override when present.
The 2.6 xfail commit (0ed4799) used the working name `#desc` in its
test file; this commit renames to `#jni_method_descriptor` for
parallel naming with the rest of the FFI directive set (`#jni_call`,
`#jni_class`, `#jni_env`, ...). Test snapshot flips xfail → green.
125/125 examples green.
`weirdMethod :: (self: *Self) -> s32 #desc("()I");` should parse,
today's 2.5 parser expects `;` immediately after the return type
and errors at the `#desc` token. The make-green follow-up adds a
`hash_desc` lexer token and threads an optional `desc_override`
field through `JniMethodDecl`.
`Point :: #jni_class("...") { x: s32; y: s32; }` should parse,
today's 2.4 body loop sees the identifier `x`, expects `::`, hits
`:` and errors. The make-green follow-up adds a `field` variant to
`JniClassMember` and a parser branch that detects `<ident>:` (vs
`<ident>::`) as the field-decl indicator.
`Window :: #jni_class("...") { #extends View; ... }` should parse,
today's 2.3 parser doesn't recognise `#extends` as a token and the
body loop reports "expected method name". The make-green follow-up
adds `hash_extends`/`hash_implements` lexer tokens, refactors
`JniClassDecl.methods` into a `members` tagged union, and dispatches
in the body loop on the leading token.
`Math :: #jni_class("java/lang/Math") { static abs :: (n: s32) -> s32; }`
should parse, today's 2.2 parser treats `static` as a plain
identifier and errors at the following `abs`. The make-green
follow-up adds a `static` keyword recognition step in the body
loop and an `is_static` flag on `JniMethodDecl`.