ffi: define-by-default #jni_class + #foreign modifier + #jni_main token

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.
This commit is contained in:
agra
2026-05-20 12:46:40 +03:00
parent 60f3ffed46
commit 8d1816018a
16 changed files with 136 additions and 25 deletions

View File

@@ -1,5 +1,5 @@
// Phase 2 step 2.0 (PLAN-FFI.md): xfail parser test for the
// type-introducer `Foo :: #jni_class("java/path/Foo") { }` directive.
// type-introducer `Foo :: #foreign #jni_class("java/path/Foo") { }` directive.
//
// Today's parser doesn't recognize `#jni_class` as a known hash
// directive after `::`, so it falls through to expression parsing
@@ -10,7 +10,7 @@
#import "modules/std.sx";
Foo :: #jni_class("java/path/Foo") { }
Foo :: #foreign #jni_class("java/path/Foo") { }
main :: () -> s32 {
print("parse-only ok\n");

View File

@@ -10,7 +10,7 @@
// `Self` here refers to the enclosing `View` type — resolved at
// 2.x sema, not at parse time.
View :: #jni_class("android/view/View") {
View :: #foreign #jni_class("android/view/View") {
getId :: (self: *Self) -> s32;
}

View File

@@ -9,7 +9,7 @@
#import "modules/std.sx";
Math :: #jni_class("java/lang/Math") {
Math :: #foreign #jni_class("java/lang/Math") {
static abs :: (n: s32) -> s32;
}

View File

@@ -12,9 +12,9 @@
#import "modules/std.sx";
View :: #jni_class("android/view/View") { }
View :: #foreign #jni_class("android/view/View") { }
Window :: #jni_class("android/view/Window") {
Window :: #foreign #jni_class("android/view/Window") {
#extends View;
getDecorView :: (self: *Self) -> *View;
}

View File

@@ -8,7 +8,7 @@
#import "modules/std.sx";
Point :: #jni_class("android/graphics/Point") {
Point :: #foreign #jni_class("android/graphics/Point") {
x: s32;
y: s32;
}

View File

@@ -10,7 +10,7 @@
#import "modules/std.sx";
Foo :: #jni_class("com/example/Foo") {
Foo :: #foreign #jni_class("com/example/Foo") {
weirdMethod :: (self: *Self) -> s32 #jni_method_descriptor("()I");
}

View File

@@ -17,27 +17,27 @@
#import "modules/std.sx";
IFoo :: #jni_interface("com/example/IFoo") {
IFoo :: #foreign #jni_interface("com/example/IFoo") {
bar :: (self: *Self) -> s32;
}
NSString :: #objc_class("NSString") {
NSString :: #foreign #objc_class("NSString") {
length :: (self: *Self) -> s32;
}
NSCopying :: #objc_protocol("NSCopying") {
NSCopying :: #foreign #objc_protocol("NSCopying") {
copy :: (self: *Self) -> *Self;
}
URL :: #swift_class("Foundation.URL") {
URL :: #foreign #swift_class("Foundation.URL") {
absoluteString :: (self: *Self) -> *void;
}
Date :: #swift_struct("Foundation.Date") {
Date :: #foreign #swift_struct("Foundation.Date") {
timeIntervalSince1970 :: (self: *Self) -> f64;
}
Hashable :: #swift_protocol("Swift.Hashable") {
Hashable :: #foreign #swift_protocol("Swift.Hashable") {
hash :: (self: *Self) -> s32;
}

View File

@@ -8,7 +8,7 @@
#import "modules/std.sx";
Activity :: #jni_class("android/app/Activity") {
Activity :: #foreign #jni_class("android/app/Activity") {
getWindow :: (self: *Self) -> *void;
}

View File

@@ -11,21 +11,21 @@
// signatures (`getWindow :: (self: *Self) -> *Window`) still resolve
// against the bare name within the namespace.
WindowInsets :: #jni_class("android/view/WindowInsets") {
WindowInsets :: #foreign #jni_class("android/view/WindowInsets") {
getSystemWindowInsetTop :: (self: *Self) -> s32;
getSystemWindowInsetLeft :: (self: *Self) -> s32;
getSystemWindowInsetBottom :: (self: *Self) -> s32;
getSystemWindowInsetRight :: (self: *Self) -> s32;
}
View :: #jni_class("android/view/View") {
View :: #foreign #jni_class("android/view/View") {
getRootWindowInsets :: (self: *Self) -> *WindowInsets;
}
Window :: #jni_class("android/view/Window") {
Window :: #foreign #jni_class("android/view/Window") {
getDecorView :: (self: *Self) -> *View;
}
Activity :: #jni_class("android/app/Activity") {
Activity :: #foreign #jni_class("android/app/Activity") {
getWindow :: (self: *Self) -> *Window;
}

View File

@@ -551,6 +551,7 @@ pub const ForeignMethodDecl = struct {
return_type: ?*Node, // null = void
is_static: bool = false, // true for `static name :: ...`
jni_descriptor_override: ?[]const u8 = null, // `#jni_method_descriptor("(Sig)Ret")` — JNI runtime only
body: ?*Node = null, // sx-side implementation (defined-class only). null = `;`-terminated decl referencing inherited / external method.
};
pub const ForeignFieldDecl = struct {
@@ -570,6 +571,8 @@ pub const ForeignClassDecl = struct {
foreign_path: []const u8, // directive arg: "java/path/Foo" / "NSString" / "Foundation.URL"
runtime: ForeignRuntime,
members: []const ForeignClassMember = &.{},
is_foreign: bool = false, // `#foreign #...` prefix — class is provided by the foreign runtime; we only reference it
is_main: bool = false, // `#jni_main` / `#objc_main` — class is the launchable entry (Activity / UIApplicationDelegate / ...)
};
pub const JniEnvBlock = struct {

View File

@@ -3972,6 +3972,13 @@ pub const Lowering = struct {
return Ref.none;
};
if (!fcd.is_foreign) {
if (self.diagnostics) |d| {
d.addFmt(.err, span, "sx-defined foreign classes can't yet be dispatched into (class '{s}' missing '#foreign' modifier? — runtime synthesis is a follow-up)", .{fcd.name});
}
return Ref.none;
}
if (fcd.runtime != .jni_class and fcd.runtime != .jni_interface) {
if (self.diagnostics) |d| {
d.addFmt(.err, span, "method calls on '{s}' runtime not yet supported (Phase 3/4)", .{@tagName(fcd.runtime)});

View File

@@ -93,6 +93,7 @@ pub const Lexer = struct {
.{ "#implements", Tag.hash_implements },
.{ "#jni_method_descriptor", Tag.hash_jni_method_descriptor },
.{ "#jni_env", Tag.hash_jni_env },
.{ "#jni_main", Tag.hash_jni_main },
};
inline for (directives) |d| {
const keyword = d[0];

View File

@@ -1509,6 +1509,7 @@ pub const Server = struct {
.hash_implements,
.hash_jni_method_descriptor,
.hash_jni_env,
.hash_jni_main,
=> ST.keyword,
.kw_f32, .kw_f64, .kw_Type, .kw_Self => ST.type_,

View File

@@ -209,10 +209,15 @@ pub const Parser = struct {
return self.parseProtocolDecl(name, start_pos);
}
// Foreign-type binding: name :: #jni_class / #jni_interface / #objc_class /
// #objc_protocol / #swift_class / #swift_struct / #swift_protocol ("path") { body }
if (self.foreignRuntimeForCurrent()) |runtime| {
return self.parseForeignClassDecl(name, start_pos, runtime);
// Foreign-type binding with optional prefix modifiers:
// [#foreign | #jni_main]* (#jni_class / #jni_interface / #objc_class /
// #objc_protocol / #swift_class / #swift_struct / #swift_protocol) ("path") { body }
//
// Define-by-default: bare `#jni_class("...")` declares a new class (sx-defined).
// `#foreign` flips that to "reference an existing class on the foreign side."
// `#jni_main` flags the class as the launchable entry (Android Activity).
if (self.tryParseForeignClassPrefix()) |prefix| {
return self.parseForeignClassDecl(name, start_pos, prefix.runtime, prefix.is_foreign, prefix.is_main);
}
// C-style union declaration
@@ -1041,7 +1046,70 @@ pub const Parser = struct {
};
}
fn parseForeignClassDecl(self: *Parser, name: []const u8, start_pos: u32, runtime: ast.ForeignRuntime) anyerror!*Node {
const ForeignClassPrefix = struct {
runtime: ast.ForeignRuntime,
is_foreign: bool,
is_main: bool,
};
/// Recognise an optional sequence of `#foreign` / `#jni_main` modifiers
/// followed by a type-introducer directive (`#jni_class`, `#objc_class`,
/// ...). Returns null if the current position isn't a foreign-class
/// directive (possibly after modifiers). Consumes the modifier tokens
/// only when a runtime directive follows; otherwise leaves the parser
/// state untouched.
fn tryParseForeignClassPrefix(self: *Parser) ?ForeignClassPrefix {
// Peek ahead through modifier tokens to confirm a directive follows.
var lookahead_idx: usize = 0;
var is_foreign = false;
var is_main = false;
while (true) {
const tag = self.peekTag(lookahead_idx);
switch (tag) {
.hash_foreign => {
is_foreign = true;
lookahead_idx += 1;
},
.hash_jni_main => {
is_main = true;
lookahead_idx += 1;
},
else => break,
}
}
const runtime = self.foreignRuntimeForOffset(lookahead_idx) orelse return null;
// Commit: consume modifier tokens.
var i: usize = 0;
while (i < lookahead_idx) : (i += 1) self.advance();
return .{ .runtime = runtime, .is_foreign = is_foreign, .is_main = is_main };
}
fn peekTag(self: *Parser, offset: usize) Tag {
if (offset == 0) return self.current.tag;
var lexer_copy = self.lexer;
var tok: Token = undefined;
var i: usize = 0;
while (i < offset) : (i += 1) {
tok = lexer_copy.next();
}
return tok.tag;
}
fn foreignRuntimeForOffset(self: *Parser, offset: usize) ?ast.ForeignRuntime {
const tag = self.peekTag(offset);
return switch (tag) {
.hash_jni_class => .jni_class,
.hash_jni_interface => .jni_interface,
.hash_objc_class => .objc_class,
.hash_objc_protocol => .objc_protocol,
.hash_swift_class => .swift_class,
.hash_swift_struct => .swift_struct,
.hash_swift_protocol => .swift_protocol,
else => null,
};
}
fn parseForeignClassDecl(self: *Parser, name: []const u8, start_pos: u32, runtime: ast.ForeignRuntime, is_foreign: bool, is_main: bool) anyerror!*Node {
self.advance(); // skip directive token
try self.expect(.l_paren);
@@ -1149,7 +1217,15 @@ pub const Parser = struct {
try self.expect(.r_paren);
}
try self.expect(.semicolon);
// Method body is optional: `;` → declaration (foreign or inherited
// method we just want to call); `{ ... }` → sx-side implementation
// for sx-defined classes.
var body_node: ?*Node = null;
if (self.current.tag == .l_brace) {
body_node = try self.parseBlock();
} else {
try self.expect(.semicolon);
}
try members.append(self.allocator, .{ .method = .{
.name = member_name,
@@ -1158,6 +1234,7 @@ pub const Parser = struct {
.return_type = return_type,
.is_static = is_static,
.jni_descriptor_override = desc_override,
.body = body_node,
} });
}
try self.expect(.r_brace);
@@ -1167,6 +1244,8 @@ pub const Parser = struct {
.foreign_path = foreign_path,
.runtime = runtime,
.members = try members.toOwnedSlice(self.allocator),
.is_foreign = is_foreign,
.is_main = is_main,
} });
}

View File

@@ -909,6 +909,25 @@ pub const Analyzer = struct {
},
.foreign_class_decl => |fd| {
try self.addSymbol(fd.name, .type_alias, null, node.span);
if (fd.is_foreign and fd.is_main) {
try self.diagnostics.append(self.allocator, .{
.level = .err,
.message = "'#foreign' and '#jni_main' / '#objc_main' are mutually exclusive — a foreign-referenced class can't be the app's main entry",
.span = node.span,
});
}
if (fd.is_foreign) {
for (fd.members) |m| switch (m) {
.method => |md| if (md.body != null) {
try self.diagnostics.append(self.allocator, .{
.level = .err,
.message = "methods on a '#foreign' class can't have bodies — they reference foreign-runtime implementations",
.span = node.span,
});
},
else => {},
};
}
},
.jni_env_block => |eb| {
try self.analyzeNode(eb.env);

View File

@@ -126,6 +126,7 @@ pub const Tag = enum {
hash_implements, // `#implements Alias;` inside a foreign-class body
hash_jni_method_descriptor, // `#jni_method_descriptor("(Sig)Ret")` per-method JNI descriptor override
hash_jni_env, // `#jni_env(env) { body }` block-form env-scoping intrinsic
hash_jni_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity
triple_minus, // ---
// Special