diff --git a/src/ir/ir.zig b/src/ir/ir.zig index d13104c..d7a614c 100644 --- a/src/ir/ir.zig +++ b/src/ir/ir.zig @@ -40,6 +40,7 @@ pub const resolveAstType = type_bridge.resolveAstType; pub const bridgeType = type_bridge.bridgeType; pub const jni_descriptor = @import("jni_descriptor.zig"); +pub const jni_java_emit = @import("jni_java_emit.zig"); pub const types_tests = @import("types.test.zig"); pub const inst_tests = @import("inst.test.zig"); @@ -50,6 +51,7 @@ pub const lower_tests = @import("lower.test.zig"); pub const type_bridge_tests = @import("type_bridge.test.zig"); pub const emit_llvm_tests = @import("emit_llvm.test.zig"); pub const jni_descriptor_tests = @import("jni_descriptor.test.zig"); +pub const jni_java_emit_tests = @import("jni_java_emit.test.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/ir/jni_java_emit.test.zig b/src/ir/jni_java_emit.test.zig new file mode 100644 index 0000000..36b94cf --- /dev/null +++ b/src/ir/jni_java_emit.test.zig @@ -0,0 +1,237 @@ +// Tests for jni_java_emit.zig — #jni_main pipeline slice 1. +// Locks in the Java source emitted from `ForeignClassDecl` AST nodes: +// package split, class header, @Override delegate pattern, primitive +// type mapping, cross-class refs through the foreign_class registry. + +const std = @import("std"); +const ast = @import("../ast.zig"); +const emit = @import("jni_java_emit.zig"); + +const Node = ast.Node; + +fn makeTypeExpr(allocator: std.mem.Allocator, name: []const u8) !*Node { + const node = try allocator.create(Node); + node.* = .{ + .span = .{ .start = 0, .end = 0 }, + .data = .{ .type_expr = .{ .name = name } }, + }; + return node; +} + +fn makePointer(allocator: std.mem.Allocator, pointee: *Node) !*Node { + const node = try allocator.create(Node); + node.* = .{ + .span = .{ .start = 0, .end = 0 }, + .data = .{ .pointer_type_expr = .{ .pointee_type = pointee } }, + }; + return node; +} + +/// Marker for "method has a body" — emitJavaSource only checks +/// `body != null`. The actual node contents are unused. +fn makeBodyMarker(allocator: std.mem.Allocator) !*Node { + const node = try allocator.create(Node); + node.* = .{ + .span = .{ .start = 0, .end = 0 }, + .data = .{ .block = .{ .stmts = &.{} } }, + }; + return node; +} + +test "rejects non-main decl" { + const a = std.testing.allocator; + const fcd: ast.ForeignClassDecl = .{ + .name = "Foo", + .foreign_path = "co/example/Foo", + .runtime = .jni_class, + .is_main = false, // ← not main + }; + const result = emit.emitJavaSource(a, &fcd, .{}); + try std.testing.expectError(emit.EmitError.NotAJniMainClass, result); +} + +test "void onCreate(Bundle) with #extends NativeActivity default" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const aa = arena.allocator(); + + const self_ty = try makePointer(aa, try makeTypeExpr(aa, "Self")); + const bundle_ty = try makePointer(aa, try makeTypeExpr(aa, "Bundle")); + const body = try makeBodyMarker(aa); + + var registry = std.StringHashMap([]const u8).init(a); + defer registry.deinit(); + try registry.put("Bundle", "android/os/Bundle"); + + const member: ast.ForeignClassMember = .{ .method = .{ + .name = "onCreate", + .params = &.{ self_ty, bundle_ty }, + .param_names = &.{ "self", "b" }, + .return_type = null, + .body = body, + } }; + const fcd: ast.ForeignClassDecl = .{ + .name = "SxApp", + .foreign_path = "co/swipelab/sx_runtime/SxNativeActivity", + .runtime = .jni_class, + .is_main = true, + .members = &.{member}, + }; + + const out = try emit.emitJavaSource(a, &fcd, .{ .classes = ®istry }); + defer a.free(out); + + const expected = + \\package co.swipelab.sx_runtime; + \\ + \\public class SxNativeActivity extends android.app.NativeActivity { + \\ @Override + \\ public void onCreate(android.os.Bundle b) { + \\ super.onCreate(b); + \\ sx_onCreate(b); + \\ } + \\ private native void sx_onCreate(android.os.Bundle b); + \\} + \\ + ; + try std.testing.expectEqualStrings(expected, out); +} + +test "primitive params" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const aa = arena.allocator(); + + const self_ty = try makePointer(aa, try makeTypeExpr(aa, "Self")); + const bool_ty = try makeTypeExpr(aa, "bool"); + const body = try makeBodyMarker(aa); + + const member: ast.ForeignClassMember = .{ .method = .{ + .name = "onWindowFocusChanged", + .params = &.{ self_ty, bool_ty }, + .param_names = &.{ "self", "hasFocus" }, + .return_type = null, + .body = body, + } }; + const fcd: ast.ForeignClassDecl = .{ + .name = "Sx", + .foreign_path = "co/sample/Sx", + .runtime = .jni_class, + .is_main = true, + .members = &.{member}, + }; + const out = try emit.emitJavaSource(a, &fcd, .{}); + defer a.free(out); + + try std.testing.expect(std.mem.indexOf(u8, out, "public void onWindowFocusChanged(boolean hasFocus)") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "super.onWindowFocusChanged(hasFocus);") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "sx_onWindowFocusChanged(hasFocus);") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "private native void sx_onWindowFocusChanged(boolean hasFocus);") != null); +} + +test "declaration-only methods are skipped" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const aa = arena.allocator(); + + const self_ty = try makePointer(aa, try makeTypeExpr(aa, "Self")); + const body = try makeBodyMarker(aa); + + // One bodied (override), one declaration-only (calls inherited). + const bodied: ast.ForeignClassMember = .{ .method = .{ + .name = "onCreate", + .params = &.{self_ty}, + .param_names = &.{"self"}, + .return_type = null, + .body = body, + } }; + const decl_only: ast.ForeignClassMember = .{ .method = .{ + .name = "finish", + .params = &.{self_ty}, + .param_names = &.{"self"}, + .return_type = null, + .body = null, // sx-side just *calls* this; Java's NativeActivity.finish() provides it + } }; + + const fcd: ast.ForeignClassDecl = .{ + .name = "Sx", + .foreign_path = "co/example/Sx", + .runtime = .jni_class, + .is_main = true, + .members = &.{ bodied, decl_only }, + }; + const out = try emit.emitJavaSource(a, &fcd, .{}); + defer a.free(out); + + try std.testing.expect(std.mem.indexOf(u8, out, "sx_onCreate") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "sx_finish") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "void finish(") == null); +} + +test "#extends Alias resolves through class registry" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const aa = arena.allocator(); + + const self_ty = try makePointer(aa, try makeTypeExpr(aa, "Self")); + const body = try makeBodyMarker(aa); + + const extends_member: ast.ForeignClassMember = .{ .extends = "MyParent" }; + const method_member: ast.ForeignClassMember = .{ .method = .{ + .name = "onCreate", + .params = &.{self_ty}, + .param_names = &.{"self"}, + .return_type = null, + .body = body, + } }; + + var registry = std.StringHashMap([]const u8).init(a); + defer registry.deinit(); + try registry.put("MyParent", "co/example/MyParentActivity"); + + const fcd: ast.ForeignClassDecl = .{ + .name = "Sx", + .foreign_path = "co/example/Sx", + .runtime = .jni_class, + .is_main = true, + .members = &.{ extends_member, method_member }, + }; + const out = try emit.emitJavaSource(a, &fcd, .{ .classes = ®istry }); + defer a.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "extends co.example.MyParentActivity") != null); +} + +test "default-package class (no slash in foreign_path)" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const aa = arena.allocator(); + + const self_ty = try makePointer(aa, try makeTypeExpr(aa, "Self")); + const body = try makeBodyMarker(aa); + + const member: ast.ForeignClassMember = .{ .method = .{ + .name = "onCreate", + .params = &.{self_ty}, + .param_names = &.{"self"}, + .return_type = null, + .body = body, + } }; + const fcd: ast.ForeignClassDecl = .{ + .name = "Sx", + .foreign_path = "SxNoPackage", + .runtime = .jni_class, + .is_main = true, + .members = &.{member}, + }; + const out = try emit.emitJavaSource(a, &fcd, .{}); + defer a.free(out); + + // No `package ...;` line when the foreign path has no slashes. + try std.testing.expect(std.mem.indexOf(u8, out, "package ") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "public class SxNoPackage") != null); +} diff --git a/src/ir/jni_java_emit.zig b/src/ir/jni_java_emit.zig new file mode 100644 index 0000000..8f2fd9d --- /dev/null +++ b/src/ir/jni_java_emit.zig @@ -0,0 +1,267 @@ +// 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_`; +// - emits the matching `private native ... sx_(...)` 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 `` to point at this class. +// Slice 4 emits a synthetic `JNI_OnLoad` that calls `RegisterNatives` +// to bind the `sx_` 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 ...;`. + default_extends: []const u8 = "android.app.NativeActivity", +}; + +/// 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); + try buf.appendSlice(allocator, " {\n"); + + // Two passes: @Override stubs that call super + 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 { + for (slash_path) |c| { + try buf.append(allocator, if (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 { + 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); + try buf.appendSlice(allocator, ") {\n super."); + try buf.appendSlice(allocator, md.name); + try buf.append(allocator, '('); + try emitJavaArgList(allocator, buf, md); + try buf.appendSlice(allocator, ");\n 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; +}