Files
sx/src/ir/jni_java_emit.zig
agra 632e64512b bundling: Android APK pipeline moved into sx; android.sx state-on-plat
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.
2026-05-23 01:28:32 +03:00

354 lines
13 KiB
Zig

// Java source emission for `#jni_main #jni_class("...") { ... }` decls
// (FFI plan, #jni_main pipeline slice 1).
//
// Given a `ForeignClassDecl` whose `is_main` flag is set, emit a `.java`
// source file that:
//
// - declares a `public class` at the foreign_path's package + simple
// name (e.g. `co/swipelab/Test/SxTestActivity` →
// `package co.swipelab.Test; public class SxTestActivity`);
// - extends the parent specified by `#extends Alias` (or
// `android.app.NativeActivity` by default for a #jni_main class);
// - for each method with a body, emits an `@Override` Java method
// that calls `super` then a private native delegate `sx_<method>`;
// - emits the matching `private native ... sx_<method>(...)` decls.
//
// The downstream pipeline (slice 2+) feeds this through `javac` + `d8`
// and bundles the resulting `.dex` into the APK. Slice 3 wires the
// manifest's `<activity android:name="...">` to point at this class.
// Slice 4 emits a synthetic `JNI_OnLoad` that calls `RegisterNatives`
// to bind the `sx_<method>` symbols.
//
// Type matrix covered today:
// - void return + primitive returns (s8/s16/s32/s64, u8/u16, bool,
// f32/f64)
// - `(self: *Self)` plus primitive params
// - cross-class refs (`*Foo` where Foo is another declared
// `#foreign #jni_class`) lower to Foo's foreign path → Java
// fully-qualified type
// - `*void` → `Object` (opaque jobject)
const std = @import("std");
const ast = @import("../ast.zig");
const Allocator = std.mem.Allocator;
pub const EmitError = error{
OutOfMemory,
UnsupportedType,
NotAJniMainClass,
};
pub const Options = struct {
/// Map from sx alias → foreign path of declared `#jni_class` decls.
/// Used to resolve `*Foo` cross-class refs in method signatures.
classes: ?*const std.StringHashMap([]const u8) = null,
/// Default superclass when the user doesn't write `#extends ...;`.
/// `android.app.Activity` is the standard base for Java-driven
/// Activities — `NativeActivity` is the legacy NDK path that
/// requires native_app_glue's `ANativeActivity_onCreate`.
default_extends: []const u8 = "android.app.Activity",
/// `System.loadLibrary(...)` argument for the emitted static init
/// block. When set, the emitter inserts `static { System.loadLibrary
/// (lib_name); }` so JNI native delegates can resolve at runtime.
/// When null, no static init is emitted (caller must arrange .so
/// loading some other way — e.g. another class's static init).
lib_name: ?[]const u8 = null,
};
/// Inject a `static { System.loadLibrary("<lib>"); }` block into an already-
/// rendered Java source. Used when the output path isn't known until after
/// `#run` blocks execute — `collectJniMainEmissions` runs during lowering,
/// before `BuildOptions.set_output_path(...)` has populated the lib name.
/// Returns a newly-allocated string; caller owns it.
pub fn injectLoadLibrary(allocator: Allocator, java_source: []const u8, lib_name: []const u8) ![]u8 {
const marker = " {\n";
const class_pos = std.mem.indexOf(u8, java_source, "public class ") orelse return try allocator.dupe(u8, java_source);
const brace_rel = std.mem.indexOf(u8, java_source[class_pos..], marker) orelse return try allocator.dupe(u8, java_source);
const insert_at = class_pos + brace_rel + marker.len;
// Already injected? Skip.
if (std.mem.indexOf(u8, java_source, "System.loadLibrary(") != null) {
return try allocator.dupe(u8, java_source);
}
var buf: std.ArrayList(u8) = .empty;
errdefer buf.deinit(allocator);
try buf.appendSlice(allocator, java_source[0..insert_at]);
try buf.appendSlice(allocator, " static { System.loadLibrary(\"");
try buf.appendSlice(allocator, lib_name);
try buf.appendSlice(allocator, "\"); }\n");
try buf.appendSlice(allocator, java_source[insert_at..]);
return try buf.toOwnedSlice(allocator);
}
/// Emit a `.java` source for the given foreign-class decl. Result is
/// heap-allocated through `allocator`; caller owns it.
pub fn emitJavaSource(
allocator: Allocator,
fcd: *const ast.ForeignClassDecl,
opts: Options,
) EmitError![]u8 {
if (!fcd.is_main) return EmitError.NotAJniMainClass;
var buf: std.ArrayList(u8) = .empty;
errdefer buf.deinit(allocator);
const parts = splitForeignPath(fcd.foreign_path);
if (parts.pkg.len > 0) {
try buf.appendSlice(allocator, "package ");
try appendDotted(allocator, &buf, parts.pkg);
try buf.appendSlice(allocator, ";\n\n");
}
var parent: []const u8 = opts.default_extends;
var parent_owned = false;
for (fcd.members) |m| switch (m) {
.extends => |alias| {
if (opts.classes) |reg| {
if (reg.get(alias)) |path| {
parent = try foreignPathToJavaName(allocator, path);
parent_owned = true;
break;
}
}
parent = alias;
break;
},
else => {},
};
defer if (parent_owned) allocator.free(parent);
try buf.appendSlice(allocator, "public class ");
try buf.appendSlice(allocator, parts.cls);
try buf.appendSlice(allocator, " extends ");
try buf.appendSlice(allocator, parent);
// `#implements Alias;` body items become Java `implements` clauses on the
// class header. Aliases resolve through the class registry the same way
// `#extends` does — an unmapped alias passes through verbatim (useful for
// referring to built-in JVM interfaces without declaring them).
var first_iface = true;
for (fcd.members) |m| switch (m) {
.implements => |alias| {
try buf.appendSlice(allocator, if (first_iface) " implements " else ", ");
first_iface = false;
if (opts.classes) |reg| {
if (reg.get(alias)) |path| {
try appendDotted(allocator, &buf, path);
continue;
}
}
try buf.appendSlice(allocator, alias);
},
else => {},
};
try buf.appendSlice(allocator, " {\n");
if (opts.lib_name) |ln| {
try buf.appendSlice(allocator, " static { System.loadLibrary(\"");
try buf.appendSlice(allocator, ln);
try buf.appendSlice(allocator, "\"); }\n");
}
// Fields. `name: Type;` body items render as private Java fields —
// primitive types pass through, pointer types resolve to fully
// qualified Java class names via the class registry.
for (fcd.members) |m| switch (m) {
.field => |fd| {
try buf.appendSlice(allocator, " private ");
try emitJavaType(allocator, &buf, fd.field_type, opts);
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, fd.name);
try buf.appendSlice(allocator, ";\n");
},
else => {},
};
// Two passes: @Override stubs + native delegate; then the native
// declarations.
for (fcd.members) |m| switch (m) {
.method => |md| {
if (md.body == null) continue;
if (md.is_static) continue; // TODO: static native handling
try emitOverride(allocator, &buf, md, opts);
},
else => {},
};
for (fcd.members) |m| switch (m) {
.method => |md| {
if (md.body == null) continue;
if (md.is_static) continue;
try emitNativeDecl(allocator, &buf, md, opts);
},
else => {},
};
try buf.appendSlice(allocator, "}\n");
return buf.toOwnedSlice(allocator);
}
const PathParts = struct { pkg: []const u8, cls: []const u8 };
fn splitForeignPath(foreign_path: []const u8) PathParts {
const last_slash = std.mem.lastIndexOfScalar(u8, foreign_path, '/') orelse {
return .{ .pkg = "", .cls = foreign_path };
};
return .{
.pkg = foreign_path[0..last_slash],
.cls = foreign_path[last_slash + 1 ..],
};
}
fn appendDotted(
allocator: Allocator,
buf: *std.ArrayList(u8),
slash_path: []const u8,
) EmitError!void {
// `/` and `$` both become `.` in Java source: `android/view/SurfaceHolder$Callback`
// → `android.view.SurfaceHolder.Callback`. The `$` form is the JNI-descriptor
// / class-file shape for nested classes; Java source uses `.` for both.
for (slash_path) |c| {
try buf.append(allocator, if (c == '/' or c == '$') '.' else c);
}
}
fn foreignPathToJavaName(allocator: Allocator, slash_path: []const u8) EmitError![]u8 {
var buf: std.ArrayList(u8) = .empty;
try appendDotted(allocator, &buf, slash_path);
return buf.toOwnedSlice(allocator);
}
fn emitOverride(
allocator: Allocator,
buf: *std.ArrayList(u8),
md: ast.ForeignMethodDecl,
opts: Options,
) EmitError!void {
// The Java @Override only calls the native delegate. `super.<method>(...)`
// is NOT injected — if the user wants to invoke the supertype's
// implementation (e.g. `super.onCreate(b)` for an Activity lifecycle hook
// that requires it) they call super from the sx-side body via JNI
// dispatch. This keeps the emitter free of "is this an interface method
// or a supertype override?" guesswork and matches the sx principle of
// user-space code expressing the dispatch.
try buf.appendSlice(allocator, " @Override\n public ");
try emitJavaReturnType(allocator, buf, md.return_type, opts);
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, md.name);
try buf.append(allocator, '(');
try emitJavaParamList(allocator, buf, md, opts);
// Non-void return types `return` the native delegate's result; void
// returns just call it. The user's sx-side body decides what to
// return — the Java side is a pass-through.
const has_ret = md.return_type != null;
try buf.appendSlice(allocator, ") {\n ");
if (has_ret) try buf.appendSlice(allocator, "return ");
try buf.appendSlice(allocator, "sx_");
try buf.appendSlice(allocator, md.name);
try buf.append(allocator, '(');
try emitJavaArgList(allocator, buf, md);
try buf.appendSlice(allocator, ");\n }\n");
}
fn emitNativeDecl(
allocator: Allocator,
buf: *std.ArrayList(u8),
md: ast.ForeignMethodDecl,
opts: Options,
) EmitError!void {
try buf.appendSlice(allocator, " private native ");
try emitJavaReturnType(allocator, buf, md.return_type, opts);
try buf.appendSlice(allocator, " sx_");
try buf.appendSlice(allocator, md.name);
try buf.append(allocator, '(');
try emitJavaParamList(allocator, buf, md, opts);
try buf.appendSlice(allocator, ");\n");
}
fn emitJavaReturnType(
allocator: Allocator,
buf: *std.ArrayList(u8),
ret: ?*ast.Node,
opts: Options,
) EmitError!void {
if (ret == null) {
try buf.appendSlice(allocator, "void");
return;
}
try emitJavaType(allocator, buf, ret.?, opts);
}
fn emitJavaParamList(
allocator: Allocator,
buf: *std.ArrayList(u8),
md: ast.ForeignMethodDecl,
opts: Options,
) EmitError!void {
const start: usize = if (md.is_static) 0 else 1; // skip self
for (md.params[start..], 0..) |p, i| {
if (i > 0) try buf.appendSlice(allocator, ", ");
try emitJavaType(allocator, buf, p, opts);
try buf.append(allocator, ' ');
try buf.appendSlice(allocator, md.param_names[start + i]);
}
}
fn emitJavaArgList(
allocator: Allocator,
buf: *std.ArrayList(u8),
md: ast.ForeignMethodDecl,
) EmitError!void {
const start: usize = if (md.is_static) 0 else 1;
for (md.param_names[start..], 0..) |name, i| {
if (i > 0) try buf.appendSlice(allocator, ", ");
try buf.appendSlice(allocator, name);
}
}
fn emitJavaType(
allocator: Allocator,
buf: *std.ArrayList(u8),
type_node: *ast.Node,
opts: Options,
) EmitError!void {
switch (type_node.data) {
.type_expr => |te| {
const name = javaPrimitiveName(te.name) orelse return EmitError.UnsupportedType;
try buf.appendSlice(allocator, name);
},
.pointer_type_expr => |ptr| {
const inner = ptr.pointee_type;
if (inner.data != .type_expr) return EmitError.UnsupportedType;
const target_name = inner.data.type_expr.name;
if (std.mem.eql(u8, target_name, "void")) {
try buf.appendSlice(allocator, "Object");
return;
}
if (opts.classes) |reg| {
if (reg.get(target_name)) |path| {
try appendDotted(allocator, buf, path);
return;
}
}
// Unknown alias — pass through dotted as best-effort. Sema
// should catch this earlier; tolerate for now.
try appendDotted(allocator, buf, target_name);
},
else => return EmitError.UnsupportedType,
}
}
fn javaPrimitiveName(name: []const u8) ?[]const u8 {
if (std.mem.eql(u8, name, "void")) return "void";
if (std.mem.eql(u8, name, "bool")) return "boolean";
if (std.mem.eql(u8, name, "s8")) return "byte";
if (std.mem.eql(u8, name, "u8")) return "byte";
if (std.mem.eql(u8, name, "s16")) return "short";
if (std.mem.eql(u8, name, "u16")) return "char";
if (std.mem.eql(u8, name, "s32")) return "int";
if (std.mem.eql(u8, name, "s64")) return "long";
if (std.mem.eql(u8, name, "f32")) return "float";
if (std.mem.eql(u8, name, "f64")) return "double";
return null;
}