`#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).
New `hash_jni_env` lexer token; `parsePrimary` dispatches to a small
`parseJniEnvBlock` that consumes `(env) { body }` and returns a new
`JniEnvBlock` AST node (env_expr + body block).
Sema's analyzeNode arm recurses into env + body inside a pushed
scope; findNodeAtOffset descends through both children for go-to-
definition.
Lowering treats it as a syntactic wrapper around the block: env is
evaluated for side effects, body lowers as a normal block. The TL
push/pop semantics (synthesizing the env stack so `#jni_call`'s env
arg can become optional) land in 2.16b.
`expectSemicolonAfter` recognises `jni_env_block` as block-form so
statement-position uses don't need a trailing `;` — matches `if` /
`while` / `for` / bare blocks.
Test runs through the block body and prints expected output; xfail
snapshot flips to green. 127/127 examples green.
`#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.
When a `ForeignMethodDecl` carries a non-null
`jni_descriptor_override` (parsed in step 2.6), `deriveMethod`
short-circuits — auto-derivation is skipped entirely and the override
is returned (duplicated through the caller's allocator so ownership
semantics stay uniform regardless of which branch ran).
Two new tests: override beats normal derivation, and override
bypasses cross-class refs that would otherwise fail with
`UnknownClassAlias`. Confirms the escape-hatch semantics from 2.6 —
users can paste an explicit JNI signature when auto-derivation
doesn't match (synthetic methods, ambiguous overloads, unknown-to-sx
JVM internals).
15 jni_descriptor tests pass; 126/126 examples still green.
`Context` gains an optional `classes: ?*const ClassRegistry` lookup
(sx alias → foreign path). `writeType`'s pointer-type arm now treats
`*Self` and `*Foo` uniformly: `Self` resolves to `enclosing_path`,
any other named target is looked up in the registry. Missing
registry or missing key both surface as `UnknownClassAlias`.
`DeriveError.CrossClassRefNotYetSupported` retired in favour of
`UnknownClassAlias` — the new error fires for both
"no-registry-provided" and "alias-not-in-registry", giving the
caller (later, sema with a real diagnostic) one error variant to
handle.
Four new unit tests: cross-class resolves with registry, errors
without registry, errors with empty registry, and end-to-end
`deriveMethod` with chained `*Self`/`*Foo` (`getDecorView ::
(self: *Self) -> *View → ()Landroid/view/View;`).
13 jni_descriptor tests pass; 126/126 examples still green.
New `src/ir/jni_descriptor.zig`:
- `writeType(allocator, buf, ctx, type_node)` appends one JNI
descriptor for an sx type AST node.
- `deriveMethod(allocator, ctx, method)` returns the full
`(args)ret` descriptor for a `ForeignMethodDecl`, skipping the
implicit `self` for instance methods.
- `Context.enclosing_path` resolves `*Self` to its `L<path>;` form.
Primitive table: void→V, bool→Z, s8/u8→B, s16→S, u16→C, s32→I,
s64→J, f32→F, f64→D. Arrays: `[]T` / `[*]T` / `[N]T` → `[<elem>`.
`*Self` → `L<enclosing>;`. Cross-class `*Foo` → explicit
`CrossClassRefNotYetSupported` error (lands in step 2.9 with the
ForeignClassDecl registry lookup).
Tests in `src/ir/jni_descriptor.test.zig`: primitive table coverage,
void-on-null, *Self, slice, cross-class-fail-fast, plus three
deriveMethod scenarios (instance, static, multi-param, slice param).
Step 2.8 is internal compiler work — derivation isn't observable at
the sx surface until call-site lowering at step 2.11. The cadence
rule's xfail-then-green pattern presupposes a snapshot harness that
doesn't apply to internal-only functions; the rule re-applies at
2.11 where end-to-end observation returns.
zig build test passes; 126/126 examples still green.
Six new lexer tokens (`hash_jni_interface`, `hash_objc_class`,
`hash_objc_protocol`, `hash_swift_class`, `hash_swift_struct`,
`hash_swift_protocol`) join the existing `hash_jni_class`. All seven
share the body grammar from Phases 2.1–2.6.
AST refactored: `JniClassDecl` → `ForeignClassDecl` with a
`runtime: ForeignRuntime` enum discriminator; `JniMethodDecl` →
`ForeignMethodDecl` (with `jni_descriptor_override` renamed for
clarity since it's JNI-only); `JniFieldDecl` → `ForeignFieldDecl`;
`JniClassMember` → `ForeignClassMember`. AST variant renamed
`jni_class_decl` → `foreign_class_decl`.
`parseForeignClassDecl` takes the runtime as a parameter; the
`parseConstBinding` dispatch table now maps each of the seven
directive tokens to its `ForeignRuntime` variant via
`foreignRuntimeForCurrent`. No codegen yet — Phase 3 picks up Obj-C
runtime, Phase 4 picks up Swift. Runtime-specific body items (fields,
descriptor override) are validated at sema time in later steps.
126/126 examples green.
#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`.
New `JniFieldDecl` AST struct (name + field_type); `JniClassMember`
gains a `field` variant. After consuming a member-name identifier
in the body loop, the parser branches on the next token: `:` →
field path (parse type expr + `;`), `::` → method path (existing).
`static` fields aren't part of the grammar yet and error explicitly
("static fields not yet supported"); only instance fields land here.
Lowering to JNI `Get<Type>Field` / `Set<Type>Field` arrives in 2.13.
124/124 examples green.
`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.
Two new lexer tokens `hash_extends` / `hash_implements` (global tokens,
context-meaningful inside #jni_class bodies — same pattern as #using).
`JniClassDecl.methods` refactored into `members: []const JniClassMember`,
a tagged union with `method` / `extends` / `implements` variants.
Body loop dispatches on the leading token: `#extends Alias;` /
`#implements Alias;` consume the alias name and push a non-method
member; everything else falls through to the existing method path.
The alias on the right of `#extends` is the sx-side name (resolved
to the corresponding #jni_class at sema time in a later step), not
the foreign Java path — the path lives only in the alias's own
directive arg.
123/123 examples green.
`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.
`JniMethodDecl` gains `is_static: bool = false`. parseJniClassDecl's
body loop now recognises a `static` identifier prefix (context-sensitive
— `static` stays a plain identifier elsewhere) and consumes it before
the method name, setting `is_static` on the resulting decl. Dispatch
to `GetStaticMethodID` / `CallStatic*Method` arrives in Phase 2.12.
122/122 examples green.
`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`.
New `JniMethodDecl` AST struct (name, params, param_names,
return_type — no body, foreign declaration). `JniClassDecl.body`
becomes `methods: []const JniMethodDecl`. parseJniClassDecl loops
over body items, parsing each `name :: (self: *Self, args...) -> Ret;`
similarly to parseProtocolDecl but requiring `;` (no body brace).
`static`, fields, `#extends`, `#implements`, and the other six
directive forms land in 2.3–2.7. Sema/lower still treat the decl
as an opaque type alias — descriptor derivation arrives in 2.8+.
121/121 examples green.
New `hash_jni_class` token + lexer entry, `JniClassDecl` AST node
(alias + java path; body deferred to 2.2+), `parseJniClassDecl`
consuming `("...") { }` and rejecting non-empty bodies for now.
Sema registers the alias as a type_alias symbol; LSP classifies
the directive as a keyword. The 2.0 xfail snapshot flips to
`parse-only ok`, exit 0.
120/120 examples green; zig test clean.
Today's parser doesn't recognize #jni_class as a hash directive
after `::`, so it falls through to expression parsing and errors
at the `#` token. Step 2.1 extends parseConstBinding to accept
the directive (opaque on empty body) and re-snapshots this file
to green.
Closes the Phase 1D migration for the safe-insets JNI chain. The C
function and its `#foreign` declaration in `android.sx` are gone;
all dispatch now goes through the sx-side `#jni_call` machinery
plus the JavaVM helpers landed in 1.26.
What's gone from `library/vendors/sx_android_jni/sx_android_jni.c`:
- `#include <android/native_activity.h>` and `<jni.h>` (no longer
needed without the JNI body).
- `sx_android_query_safe_insets` — 55 lines of `(*env)->Foo` chain
with manual `goto done` early-exit. Migrated to
`library/modules/platform/android.sx::sx_query_safe_insets_jni`
in 1.25 (15 lines of `#jni_call`).
What stays:
- `sx_android_install_input_handler` — non-JNI; struct-field
assignment against `struct android_app`'s `onInputEvent` slot.
No sx equivalent yet (would need to either land a `#android_app`-
style intrinsic or hand-roll the offset, neither of which is
Phase 1 scope).
- `<android/input.h>` and the `struct sx_android_app_min` mirror
needed by the input-handler installer.
Net diff: -55 lines in the .c file, -1 line `#foreign` decl in
android.sx. Phase 2 (declarative JNI imports) will revisit whether
the .c file can be deleted entirely (the input-handler hop may
move into a different shape).
Verification:
- zig build + zig test + run_examples + cross_compile all green.
Notable: the previously-failing `ffi-objc-call-12-rect-u64-returns`
also passes now — looks like the working-tree `#import c` work
was tidied up alongside.
- chess Android APK rebuilt + reinstalled + launched on Pixel
device; safe-insets behavior unchanged (board top edge sits below
the status bar correctly, all pieces in starting positions, no
status-bar overlap).
`AndroidPlatform.safe_insets` now reaches into the JVM through the
sx helpers from 1.25 + 1.26 instead of the C `sx_android_query_safe_insets`
foreign call:
attached := false;
env := sx_android_get_env(g_android_activity, @attached);
if env != null {
clazz := sx_android_activity_clazz(g_android_activity);
sx_query_safe_insets_jni(env, clazz, @t, @l, @b, @r);
if attached { sx_android_detach_env(g_android_activity); }
}
Chess Android IR now includes the seven `(@SX_JNI_CLS_*, @SX_JNI_MID_*)`
slot pairs (one per unique literal `(name, sig)` pair: getWindow,
getDecorView, getRootWindowInsets, getSystemWindowInset{Top,Left,
Bottom,Right}). First call populates each; subsequent calls hit the
cached jmethodID via the 1.17 lazy-init branch.
The C `sx_android_query_safe_insets` body is now unused; left in place
per the plan ("leave the file in place until Phase 2 deletes it").
Chess Android + iOS-sim both compile clean; host 118/119;
cross-compile 3/3.
On-device chess regression is the next checkpoint — the safe-area
behavior is visible: board must sit below the status bar with
correct top inset on a Pixel 7 Pro with notch. Deferred to the next
session (requires APK build + adb install + screencap).
Adds the JavaVM-side vtable indirection to `library/modules/platform/
android.sx` so the sx caller of `sx_query_safe_insets_jni` (1.25)
can obtain a `JNIEnv*` without the C wrapper. `#jni_call` only
dispatches through `JNIEnv*`'s vtable (a different table from
`JavaVM*`'s), so the JavaVM hop is hand-rolled here.
New decls:
- `JNI_VERSION_1_6` (0x00010006) and the `ANATIVEACTIVITY_*` byte
offsets (8, 24 on 64-bit Android — vm, clazz respectively).
- `sx_load_ptr_at(base, offset)` — load a `*void` field at a raw
byte offset. Used for both ANativeActivity fields and the JavaVM
vtable load.
- `sx_load_javavm_fn(vm, slot)` — load function pointer at the
given vtable slot. `vm` is `JavaVM*` which points to
`JNIInvokeInterface*`; the indirection is `*vm + slot * 8`.
- `sx_android_get_env(activity, out_attached)` — calls `GetEnv`
(slot 6); on `JNI_EDETACHED` falls through to
`AttachCurrentThread` (slot 4), sets `out_attached = true` so
caller can balance with `sx_android_detach_env` (slot 5).
- `sx_android_activity_clazz(activity)` — reads the jobject at byte
offset 24.
Chess Android + iOS-sim builds still clean; cross-compile 3/3
green; host 118/119. The new functions dead-strip until step 1.27
wires them into the safe-insets call site in
`android.sx::AndroidPlatform.safe_insets`.
Phase 1D for `library/vendors/sx_android_jni/sx_android_jni.c` starts
here. Adds `sx_query_safe_insets_jni` to `library/modules/platform/
android.sx` — a sx-side implementation of the JNI dispatch chain
that lives inside the C `sx_android_query_safe_insets` helper.
The C version is ~50 lines of `(*env)->GetMethodID` + `CallObjectMethod`
+ `CallIntMethod` boilerplate with manual `goto done` early-exit
plumbing on every step. The sx version collapses to four
`#jni_call(*void)` chain steps + four `#jni_call(s32)` reads at the
end — each #jni_call internally handles GetObjectClass + GetMethodID
+ Call<Type>Method via the slot interning from 1.17.
Signature differences from the C version:
- The sx version takes `env: *void` directly. The C version derives
it from `ANativeActivity*` via JavaVM's GetEnv/AttachCurrentThread.
Bridging that gap (sx-side JavaVM dispatch OR a tiny C shim that
returns the env) is the next Phase 1D step.
- The activity arg here is the jobject (`ANativeActivity*.clazz`)
rather than the activity pointer itself.
No call sites switched yet. Chess Android still uses the foreign C
function. Cross-compile + chess both targets all clean — verifies
the new function typechecks and lowers, but on-device runtime
verification is deferred to the integration commit.
Adds `ios-sim|examples/ffi-jni-call-02-void.sx` to the cross-compile
tuple list. The `inline if OS == .android { #jni_call(...) }` arm in
that example must strip its body before sema/lower runs on iOS,
otherwise emit_llvm would attempt to load libjvm vtable slots that
don't exist in the iOS SDK and the link step would fail.
This is the JNI mirror of step 1.14, which did the same for
`#objc_call` against Android. Phase 1C is functionally complete:
- Parser accepts all three FFI intrinsics (1.1–1.2)
- `#objc_call` full return-type matrix + selector interning (1.3–1.10)
- `#objc_call` enclosing-construct coverage (1.11–1.13)
- `#objc_call` cross-Android gate (1.14)
- `#jni_call(void)` codegen with vtable indirection (1.15)
- `#jni_call` literal-keyed slot interning (1.16–1.17)
- `#jni_call` return-type matrix s32/s64/f64/bool/*void (1.18–1.22)
- `#jni_static_call` lowering (1.23)
- `#jni_call` cross-iOS gate (1.24, this commit)
3/3 cross-compile tuples pass; 118/119 host tests pass (one
unrelated regression in working tree). Next: Phase 1D for
`library/vendors/sx_android_jni/sx_android_jni.c` — migrate the C
JNI helpers to sx via `#jni_call`. Requires on-device chess
verification per the FFI plan.
Static dispatch wired in. The early `is_static` bail in
`.jni_msg_send` is gone; both paths now share the same lazy-cache +
phi structure with two static-specific differences:
1. `GetObjectClass` is skipped — for static calls, `target` IS the
`jclass`. The cached `cls` slot just stores `NewGlobalRef(target)`
directly.
2. The method-ID lookup uses `GetStaticMethodID` (slot 113), and the
dispatch uses `CallStatic<Type>Method` (Object 114 / Boolean 117
/ Int 129 / Long 132 / Float 135 / Double 138 / Void 141).
Slot interning still applies: the `@SX_JNI_{CLS,MID}_<key>` pair is
shared between instance and static literal call sites with the same
`(name, sig)` — though in practice the JNI runtime treats instance
and static method-IDs as distinct, so two sites with the same name
but different dispatch kinds would collide in the cache. This isn't
a problem the chess Android backend hits (each method is uniquely
either static or instance in the API), so the simpler single-key
intern stays.
IR snapshot updated: `ret i32 undef` replaced by the full
NewGlobalRef → GetStaticMethodID → CallStaticIntMethod sequence
through vtable slots 21, 113, 129. Args `i32 3, i32 7` thread through
the existing arg-coercion loop.
Test-add for static dispatch — `#jni_static_call(s32)(env, cls,
"max", "(II)I", 3, 7)` exercises GetStaticMethodID + CallStaticIntMethod
plus two integer args. Today the lowering bails on `is_static = true`
with `LLVMGetUndef`. IR snapshot captures the placeholder.
The next commit:
- Adds `Jni.GetStaticMethodID` (113), `Jni.CallStaticVoidMethod` (141),
`Jni.CallStaticIntMethod` (129), etc. to the constants struct.
- Wires the static path: skip `GetObjectClass` (`target` IS the
jclass), `NewGlobalRef(target)` to cache it, `GetStaticMethodID`
for the method, then `CallStatic<Type>Method` per return type.
Closes the return-type matrix. Pointer-return types aren't a simple
`TypeId` enum case (they're user-defined types interned into the
table), so the dispatch checks `TypeInfo.pointer | .many_pointer`
ahead of the primitive switch:
const is_pointer_ret = switch (types.get(ret_ty_id)) {
.pointer, .many_pointer => true,
else => false,
};
const offset = if (is_pointer_ret)
Jni.CallObjectMethod
else switch (ret_ty_id) { .void => ..., .s32 => ..., ... };
LocalRef cleanup deferred: returned jobjects are JNI LocalRefs
bounded by the native frame. Chains of calls within one frame
consume them inline; cross-frame use must promote via `NewGlobalRef`
(already wired in the slot-interning path from 1.17). The chess
Android backend will consume objects inline, matching the manual
pattern in `sx_android_jni.c`.
Return-type matrix done: void, s32, s64, f64, bool, *void all
dispatch through their respective vtable slots. Static dispatch
(1.23) is next.
Last return-type variant in the matrix. JNI's jobject is a pointer
(LocalRef) — sx's `*void` maps to LLVM `ptr` directly. CallObjectMethod
is at vtable slot 34. IR snapshot captures today's `ret ptr undef`.
Next commit adds the `.ptr => Jni.CallObjectMethod` arm.
LocalRef lifetime: the returned jobject is a JNI LocalRef bounded by
the native frame. Chains of calls within one frame consume LocalRefs
inline; calls that need to escape the frame should be promoted via
`NewGlobalRef` (already wired in the slot-interning path). Step 1.22
doesn't introduce automatic cleanup — chess use consumes objects
inline, matching the pattern in sx_android_jni.c.
One-line addition: `.bool => Jni.CallBooleanMethod`. The lazy-cache
+ dispatch from 1.17 handles the rest. JNI's `jboolean` is i8 in the
C ABI but always carries 0 or 1; LLVM's call boundary truncates the
return byte to i1 and the sx-level bool reads the low bit
canonically.
IR snapshot updated: `ret i1 undef` replaced by the full sequence
through vtable slot 37 keyed on `("isShown", "()Z")`.
Test-add for the jboolean return. JNI `jboolean` is a single byte (0
or 1); sx's `bool` lowers to LLVM `i1` with byte-coercion at the ABI
boundary. CallBooleanMethod is at vtable slot 37.
IR snapshot captures today's `ret i1 undef`. Next commit adds the
`.bool => Jni.CallBooleanMethod` arm.
One-line addition to the switch: `.f64 => Jni.CallDoubleMethod`.
First non-integer JNI return type; same lazy-cache + dispatch
infrastructure from 1.17 handles the rest.
IR snapshot updated: `ret double undef` replaced by the full
sequence through vtable slot 58 keyed on `("getValue", "()D")`.
Test-add for the jdouble return-type variant — `#jni_call(f64)(env,
target, "getValue", "()D")`. First non-integer return type for JNI.
IR snapshot captures today's `ret double undef` placeholder. The
next commit adds the `.f64 => Jni.CallDoubleMethod` arm.
One-line addition to the `call_method_offset` switch: `.s64 =>
Jni.CallLongMethod`. The 1.17 caching infrastructure and the named-
constants struct from c1877fc handle the rest.
IR snapshot at `tests/expected/ffi-jni-call-05-jlong-return.ir`
updated: `ret i64 undef` replaced by the full lazy-cache +
CallLongMethod (vtable slot 52) sequence keyed on
`("currentTimeMillis", "()J")`.
Test-add for the jlong return-type variant — same shape as 1.18's
jint test but exercising `#jni_call(s64)(env, target,
"currentTimeMillis", "()J")`. Today the non-void switch falls
through to `LLVMGetUndef`; the IR snapshot captures the placeholder.
The next commit adds the `.s64 => Jni.CallLongMethod` arm. The
snapshot will update to show the full dispatch through vtable slot
52, reusing the 1.17 slot interning machinery.
The numeric slot indices (21, 31, 33, 49, 61) in the `#jni_call`
lowering are JNI-spec constants from `<jni.h>` but appeared as bare
magic numbers — only the trailing comment told you which JNI
function you were loading. Moving them into a private `const Jni`
namespace at file scope makes the call sites self-documenting:
loadJniFn(ifs, Jni.GetObjectClass, "jni.GetObjectClass")
loadJniFn(ifs, Jni.NewGlobalRef, "jni.NewGlobalRef")
loadJniFn(ifs, Jni.GetMethodID, "jni.GetMethodID")
switch (ret_ty_id) {
.void => Jni.CallVoidMethod,
.s32 => Jni.CallIntMethod,
...
}
Also pre-loaded the remaining Call<Type>Method slots (Object,
Boolean, Long, Float, Double) so steps 1.19–1.22 just add the
corresponding switch arm — no new magic-number lookups in the diff.
Behavior-preserving refactor: IR snapshots unchanged, all 113 host
tests still pass, both cross-compile tuples still green.
One-line addition to the `call_method_offset` switch in
`emit_llvm.zig` — `.s32 => 49` (CallIntMethod). The 1.17 caching
infrastructure handles the rest: GetObjectClass → NewGlobalRef →
GetMethodID populate the shared `@SX_JNI_{CLS,MID}_<key>` pair on
miss; per-call lowering loads the cached jmethodID and dispatches
through vtable slot 49 with an `i32` return.
IR snapshot at `tests/expected/ffi-jni-call-04-jint-return.ir`
updated: the `ret i32 undef` placeholder is replaced by the full
lazy-cache + CallIntMethod sequence keyed on
`("getCount", "()I")`. Pre-1.18 snapshot was 1d7ea72.
Adds `examples/ffi-jni-call-04-jint-return.sx` exercising
`#jni_call(s32)(env, target, "getCount", "()I")` inside a runtime-
reachable but never-invoked helper (`g_should_call` stays false, so
the dereferences don't fire). Today the emit_llvm switch falls
through to `LLVMGetUndef` for any non-void return — the IR snapshot
captures that placeholder.
The next commit adds the `.s32 => 49` (CallIntMethod) arm. The
snapshot will update to show the full GetObjectClass → GetMethodID →
CallIntMethod sequence (reusing the slot interning landed in 1.17,
since `("getCount", "()I")` is a fresh literal pair).
Two `#jni_call` sites with the same string-literal `(name, sig)` pair
now share a single `jclass` GlobalRef slot and a single `jmethodID`
slot, populated lazily on the first call to any matching site.
Non-literal sites keep the per-call `GetObjectClass` + `GetMethodID`
sequence from step 1.15.
Per-call-site lowering for literal sites:
%cached_mid = load ptr, @SX_JNI_MID_<key>
%is_cached = icmp ne ptr %cached_mid, null
br i1 %is_cached, cont, miss
miss:
%local_cls = GetObjectClass(env, target)
%global_cls = NewGlobalRef(env, local_cls) ; vtable slot 21
store ptr %global_cls, @SX_JNI_CLS_<key>
%fresh_mid = GetMethodID(env, global_cls, name, sig)
store ptr %fresh_mid, @SX_JNI_MID_<key>
br cont
cont:
%mid = phi ptr [%cached_mid, before], [%fresh_mid, miss]
call <Type>Method(env, target, %mid, args...)
Wiring:
- `JniMsgSend.cache_key: ?CacheKey` (new) carries `(name_str,
sig_str)` when both `name` and `sig` are string-literal AST nodes;
empty for non-literal call sites.
- `lower.zig` populates `cache_key` from the AST.
- `emit_llvm.zig` `getOrCreateJniSlots(name, sig)` returns the
`{cls_slot, mid_slot}` pair, creating and caching them on first
lookup. Key is `name\x00sig` so the separator can't collide with
any JNI identifier byte.
- `mangleJniKey` builds an LLVM-identifier suffix from the pair, used
in the `@SX_JNI_{CLS,MID}_<suffix>` global names.
IR snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir`
updated: two call sites against literal `("noop", "()V")` now share
`@SX_JNI_CLS_noop____V` and `@SX_JNI_MID_noop____V`. Pre-1.17 snapshot
had two independent `GetMethodID` calls; post-1.17 has one global
slot pair plus per-call lazy-init branches.
Note: an unrelated regression in `examples/ffi-objc-call-12-rect-u64-returns.sx`
exists in the working tree (parse error from an in-progress C-import
block) and is left untouched.
Adds `examples/ffi-jni-call-03-methodid-sharing.sx` with two
`#jni_call` sites against the same (class, method, sig). Today each
site emits its own `GetObjectClass` + `GetMethodID` + `Call<Type>Method`
sequence (8 vtable indirections total for the two-call test); 1.17
will collapse the two `GetMethodID` calls into a single cached
`jmethodID` static slot populated at module init, mirroring the
`OBJC_SELECTOR_REFERENCES_*` shape that 1.5 introduced for `#objc_call`.
Runtime is a no-op — `unused_jni` is reachable through a
runtime-readable `g_should_call` global that stays false, so the JNI
dereferences never execute. A plain `if false` would get
constant-folded, taking the function definition out of the IR
entirely; the global keeps both the function and its body present
for the IR-snapshot harness.
IR snapshot at `tests/expected/ffi-jni-call-03-methodid-sharing.ir`
locks the pre-caching shape. The next commit (1.17) updates it to the
collapsed shape.
113/113 host tests pass.
New `.jni_msg_send` IR opcode carrying `{env, target, name, sig,
args[], is_static}`. `lowerFfiIntrinsicCall` now dispatches on
`fic.kind`: `.objc_call` keeps the existing path; `.jni_call` and
`.jni_static_call` route through `lowerJniCall`, which emits the new
opcode.
emit_llvm.zig expands `.jni_msg_send` into the JNI vtable
indirection:
%ifs = load ptr, %env ; vtable
%get_obj_class = load ptr, gep(%ifs, i32 31)
%cls = call ptr %get_obj_class(%env, %target)
%get_method_id = load ptr, gep(%ifs, i32 33)
%mid = call ptr %get_method_id(%env, %cls, %name, %sig)
%call_void_method = load ptr, gep(%ifs, i32 61)
call void %call_void_method(%env, %target, %mid, args...)
Per step 1.15's scope: only `.jni_call` (instance) + `void` return
are wired through the switch. `.jni_static_call` (1.23) and the
non-void returns (1.18–1.22) drop to a placeholder `LLVMGetUndef` so
the build doesn't fault — the next-step commits flip those arms one
shape at a time. Method-ID caching is step 1.17.
Two small helpers landed alongside:
- `loadJniFn(ifs, offset, name)` — GEP into the vtable + load.
- `extractSlicePtr(val)` — string literals lower as `{ptr, i64}`
slices in sx IR; JNI's `GetMethodID` expects raw C strings, so
this extracts field 0 when the source is a slice.
Android cross-compile now passes for `examples/ffi-jni-call-02-void.sx`
(2/2 cross targets green). Host run_examples still passes 112/112.
Chess iOS-sim + Android both compile clean.
Adds `examples/ffi-jni-call-02-void.sx` exercising `#jni_call(void)
(env, target, "name", "sig")` inside an `inline if OS == .android`
arm, plus a new tuple in `tests/cross_compile.sh`. Host run_examples
passes (the inline-if strips the JNI body, leaving "skipped"); the
Android cross-compile FAILs because `lowerFfiIntrinsicCall` still
emits the placeholder diagnostic for any `fic.kind != .objc_call`.
Per the FFI cadence rule this is a test-add (xfail); the next
commit makes the Android cross-compile green by adding the
`.jni_msg_send` opcode and its emit_llvm expansion.
Closes the runtime-verification gap from cluster 1.32. The migrated
`uikit_keyboard_will_change_frame` body uses both shapes but isn't
reached by chess startup (the soft keyboard doesn't open without user
input), so runtime verification was transitive only: `#objc_call(CGRect)`
via the structurally-identical `#objc_call(UIEdgeInsets)` (4×f64 HFA)
in ffi-objc-call-07, and `#objc_call(u64)` via the LLVM-equivalent
`#objc_call(s64)` `hash` test in ffi-objc-call-04.
This example installs two IMPs via `class_addMethod`:
- `rect_imp` returns a CGRect of {10.5, 20.5, 30.5, 40.5} through the
32-byte HFA path (v0..v3 on AAPCS64).
- `u64_imp` returns `0x7FEDCBA987654321` through the i64 path.
`#objc_call(CGRect)` and `#objc_call(u64)` dispatch through them and
the values are printed for snapshot lockdown.
Reused the parser quirk noted in the checkpoint and in 0.1 — integer
literals ≥ 2^63 are rejected even when the receiving type is u64, so
the test value keeps the high bit clear.
111/111 host tests pass.
`collectCaptures` in `src/ir/lower.zig` was the closure free-variable
analyzer that decides which names from a closure body need to be
boxed into the env struct at lambda-build time. Its switch on AST
node kind enumerated every other shape (`.call`, `.if_expr`,
`.match_expr`, `.for_expr`, etc.) but no arm for `.ffi_intrinsic_call`,
so the trailing `else => {}` quietly dropped its `args[]` and
`return_type` walks. Names referenced inside `#objc_call(T)(recv,
"sel:", ...)` from a closure body never made it into the captures
list, so when lowering bound the closure scope from env, those names
came back as "unresolved".
The fix adds the missing arm — walk `return_type` and every `args[i]`
the same way `.call` walks `callee` + `args`.
Companion changes:
- `examples/issue-0038.sx` → `examples/103-ffi-closure-capture.sx`
(out of the open-issue namespace; comment header tightened to
describe the feature, not the historical bug).
- `examples/ffi-objc-call-09-in-construct.sx` drops the
`g_hasher_recv` module-global workaround that was added for this
bug — the closure now captures `recv` from `make_hasher`'s arg
list normally.
Uncomments the second passthrough case in `examples/issue-0038.sx`
that captures `recv` from the enclosing function into a closure body
that uses it inside `#objc_call(s64)(recv, "hash")`. Current behavior
is a hard error from the name-resolution pass:
examples/issue-0038.sx:28:48: error: unresolved: 'recv'
Snapshot locks the failure in (exit 1 + that error message) so the
next commit can flip it to passing without ambiguity. Per the FFI
cadence rule this is a test-add (xfail); the make-green follow-up
adds the missing recursion arm in `lower.zig`'s `collectCaptures` for
`.ffi_intrinsic_call` nodes.
Six remaining dispatch clusters migrated in one pass:
- `uikit_setup_renderbuffer`: `renderbufferStorage:fromDrawable:` (BOOL).
- `uikit_present_renderbuffer`: `presentRenderbuffer:` (BOOL, every frame).
- `uikit_gl_view_tick`: `targetTimestamp` and `duration` reads (f64,
every frame — three call sites total across the keyboard-anim path
and the frame-closure path).
- `uikit_compute_layer_pixel_size`: `bounds` (CGRect HFA).
- `uikit_touch_location`: `locationInView:` (CGPoint HFA — first
standalone `#objc_call(CGPoint)` exercise, structurally identical to
the 2×f64 NSPoint already verified by ffi-objc-call-05).
- `uikit_first_touch`: `anyObject` (*void).
Net -15 lines. uikit.sx is now 839 lines — Phase 1D started at 937,
so this is -98 cumulative across the migration. Zero `xx objc_msgSend`
typed casts left in the file.
iOS-sim chess regression smoke: launched chess, tapped a black pawn
through the Simulator window, watched the move (d7→d5) play, then a
second tap played d5→d4. The render loop, touch handlers, layout
math, and the BOOL-returning EAGL presentation calls are all on the
exercised path, so this is the strongest runtime verification any
Phase 1D commit has had so far.
22 `sel_registerName` calls remain in the file, all legitimate:
- `class_addMethod` IMP registrations (runtime class build-out).
- SEL-as-arg to dispatch selectors that take a SEL value
(`addObserver:selector:name:object:`,
`displayLinkWithTarget:selector:`). A future `#objc_selector("foo")`
literal would replace these, but it's not part of Phase 1.
The keyboard notification callback. First standalone exercises of
`#objc_call(CGRect)` (HFA — structurally equivalent to UIEdgeInsets,
already verified by 1.25 and ffi-objc-call-07) and `#objc_call(u64)`
(LLVM-equivalent to s64; ffi-objc-call-04 already locks in the i64
return path).
Migrates:
- `userInfo` (*void)
- `objectForKey:` with NSString arg (*void)
- `CGRectValue` (CGRect HFA)
- `doubleValue` (f64)
- `unsignedLongValue` (u64)
- `screen` (*void)
- `bounds` (CGRect HFA)
Net -14 lines. uikit.sx now 854 lines (-83 cumulative across Phase 1D).
iOS-sim chess regression smoke: launch is clean; the callback is
registered through cluster 1.30's notification-center wiring and the
function lowers without IR-verifier complaints. The callback body
itself isn't exercised at runtime by chess startup (the game doesn't
open the soft keyboard) — runtime verification of this specific
function is transitive via the other clusters that exercise the same
call shapes.
The biggest Phase 1D cluster: the iOS scene-lifecycle entry that runs
at every launch. UIWindow alloc/init, UIViewController alloc/init, GL
view alloc/init/install, root-view-controller wiring, layer access +
setOpaque:, EAGL drawable-properties dictionary build,
screen/nativeScale DPI scaling, makeKeyAndVisible, UITextField subview
install, CADisplayLink construct + addToRunLoop. Every return shape
this file uses (void, *void, f64) and every arg shape (BOOL via `xx
0`/`xx 1`, multi-arg selectors `displayLinkWithTarget:selector:` and
`setObject:forKey:`) is exercised by this single launch.
Net -44 lines on this commit (104 → 60). Also drops a stale
`EAGLContext := objc_getClass(...)` decl that wasn't referenced inside
this function — EAGL context creation lives in uikit_create_gl_context
(already migrated in 1.29). uikit.sx is now 868 lines (-69 cumulative
across Phase 1D).
iOS-sim chess regression smoke: app launches cleanly, board renders
with status-bar clearance, sharp DPI scaling, compositor working,
display-link tick driving frames. Every part of the migrated function
is on the launch path and all of it succeeds.
Closes the runtime-verification gap from cluster 1.28: chess startup
doesn't reach the keyboard `becomeFirstResponder` / `resignFirstResponder`
path, so `#objc_call(bool)` was only compile-verified. This example
installs two BOOL-returning IMPs via `class_addMethod` (type encoding
"B@:") and dispatches both through `#objc_call(bool)`. Also exercises
the nil-receiver guarantee (libobjc returns a zero slot, which decodes
as false).
This is a test-add commit (per the FFI cadence rule): it locks in
current behavior without changing any lowering. Lowering shape is
identical to `#objc_call(u8)` at the ABI layer; this test makes the
source-level type explicit and gives `git bisect` a target if a
future emit_llvm change inadvertently breaks single-byte returns.
110/110 host tests pass.
Apple documents `-becomeFirstResponder` and `-resignFirstResponder` as
returning `BOOL`. The pre-`#objc_call` cast pattern in this file used
`u8` because BOOL is ABI-equivalent to a 1-byte unsigned integer on
both i386 (signed char) and arm64 (`bool`). The initial 1.28
migration carried that `u8` typing forward without question; switching
to `bool` matches the documented API and aligns with the BOOL→bool
mapping called out in PLAN-FFI.md Phase 3.
First standalone exercise of `#objc_call(bool)`. The lowering is
identical to `#objc_call(u8)` at the ABI layer (single byte in `w0`
on AAPCS64), but the source-level type is now meaningful.