Skip to content

Commit aae9acc

Browse files
committed
wrap-java: support java record types
Simple addition to support record types using existing infra. This adds a new macro but it is just sugar over the existing JavaClass macro
1 parent 47df801 commit aae9acc

8 files changed

Lines changed: 166 additions & 6 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
package com.example.swift;
16+
17+
/**
18+
* Java 16+ record used to smoke-test wrap-java's <code>@JavaRecord</code>
19+
* emission end-to-end: reflection sees this as a record, wrap-java produces
20+
* a Swift wrapper with the canonical constructor plus the {@code x()} /
21+
* {@code y()} component accessors, and the runtime test round-trips values
22+
* through the JNI boundary.
23+
*/
24+
public record Point(int x, int y) {}

‎Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"com.example.swift.HelloSubclass" : "HelloSubclass",
55
"com.example.swift.HelloJavaKitArrays" : "HelloJavaKitArrays",
66
"com.example.swift.JavaKitSampleMain" : "JavaKitSampleMain",
7+
"com.example.swift.Point" : "Point",
78
"com.example.swift.ThreadSafeHelperClass" : "ThreadSafeHelperClass"
89
}
910
}

‎Sources/SwiftJava/Macros.swift‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,46 @@ public macro JavaClass(
4545
implements: (any AnyJavaObject.Type)?...
4646
) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro")
4747

48+
/// Attached macro that declares that a particular Swift type is a wrapper around a Java `record`.
49+
///
50+
/// This behaves identically to ``JavaClass`` today (records are wrapped as
51+
/// classes on the Swift side, exposing the canonical constructor and the
52+
/// component accessor methods)
53+
///
54+
/// Usage:
55+
///
56+
/// ```swift
57+
/// @JavaRecord("com.example.Point")
58+
/// open class Point: JavaObject {
59+
/// @JavaMethod
60+
/// @_nonoverride public convenience init(_ x: Int32, _ y: Int32, environment: JNIEnvironment? = nil)
61+
///
62+
/// @JavaMethod open func x() -> Int32
63+
/// @JavaMethod open func y() -> Int32
64+
/// }
65+
/// ```
66+
///
67+
/// Would correspond to a Java record like this:
68+
/// ```java
69+
/// package com.example;
70+
///
71+
/// public record Point(int x, int y) {}
72+
/// ```
73+
@attached(
74+
member,
75+
names: named(fullJavaClassName),
76+
named(javaHolder),
77+
named(init(javaHolder:)),
78+
named(JavaSuperclass),
79+
named(`as`)
80+
)
81+
@attached(extension, conformances: AnyJavaObject)
82+
public macro JavaRecord(
83+
_ fullClassName: String,
84+
extends: (any AnyJavaObject.Type)? = nil,
85+
implements: (any AnyJavaObject.Type)?...
86+
) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro")
87+
4888
/// Attached macro that declares that a particular `struct` type is a wrapper around a Java interface.
4989
///
5090
/// Use this macro to describe a type that was implemented as an

‎Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ Java `native` functions. SwiftJava simplifies the type conversions
3030
|----------------------------------------|---------------|
3131
| Java `class` | ✅ |
3232
| Java class inheritance | ✅ |
33+
| Java methods: `static`, member | ✅ `@JavaMethod` |
3334
| Java `abstract class` | TODO |
3435
| Java `enum` | ❌ |
35-
| Java methods: `static`, member | ✅ `@JavaMethod` |
36+
| Java `record` (Java 16+) | ✅ `@JavaRecord` |
37+
| Java `sealed class` / `sealed interface` (Java 17+) | 🟡 recognized, but missing special handling of `permits` list |
3638
| **This list is very work in progress** | |
3739

38-
3940
### JExtract – calling Swift from Java
4041

4142
SwiftJava's `swift-java jextract` tool automates generating Java bindings from Swift sources.

‎Sources/SwiftJavaMacros/GenerationMode.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ enum GenerationMode {
3131
/// Determine the mode for Java class generation based on an attribute.
3232
init?(attribute: AttributeSyntax) {
3333
switch attribute.attributeName.trimmedDescription {
34-
case "JavaClass", "JavaInterface":
34+
case "JavaClass", "JavaInterface", "JavaRecord":
3535
self = .importFromJava
3636

3737
case "ExportToJavaClass":

‎Sources/SwiftJavaToolLib/JavaClassTranslator.swift‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,28 @@ extension JavaClassTranslator {
385385
return allDecls
386386
}
387387

388+
/// Render additional documentation about the Java type, e.g. if it was a `sealed` type, etc.
389+
private func renderJavaKindDocc() -> String {
390+
var lines: [String] = []
391+
if javaClass.isRecord() {
392+
lines.append("/// Java record: `\(javaClass.getName())`")
393+
}
394+
if javaClass.isSealed() {
395+
let kind = javaClass.isInterface() ? "sealed interface" : "sealed class"
396+
let permits = javaClass.getPermittedSubclasses().compactMap { $0?.getName() }
397+
if permits.isEmpty {
398+
lines.append("/// Java `\(kind)`")
399+
} else {
400+
lines.append("/// Java `\(kind)`, permits: \(permits.map({ "`\($0)`" }).joined(separator: ", "))")
401+
}
402+
log.info("Wrap Java \(kind): `\(javaClass.getName())`, permits: \(permits.map({ "`\($0)`" }).joined(separator: ", ")))")
403+
}
404+
if lines.isEmpty {
405+
return ""
406+
}
407+
return lines.joined(separator: "\n") + "\n"
408+
}
409+
388410
/// Render the declaration for the main part of the Java class, which
389411
/// includes the constructors, non-static fields, and non-static methods.
390412
private func renderPrimaryType() -> DeclSyntax {
@@ -478,16 +500,26 @@ extension JavaClassTranslator {
478500
}
479501

480502
// Emit the struct declaration describing the java class.
481-
let classOrInterface: String = isInterface ? "JavaInterface" : "JavaClass"
503+
// Records are wrapped like classes today, but tagged with @JavaRecord so the
504+
// "was a record" signal survives into the generated Swift and downstream tools.
505+
let classOrInterface: String =
506+
if isInterface {
507+
"JavaInterface"
508+
} else if javaClass.isRecord() {
509+
"JavaRecord"
510+
} else {
511+
"JavaClass"
512+
}
482513
let introducer = translateAsClass ? "open class" : "public struct"
483514
let classAvailableAttributes = swiftAvailableAttributes(
484515
from: annotations,
485516
runtimeInvisibleAnnotations: self.runtimeInvisibleAnnotations.classAnnotations,
486517
javaClass: javaClass
487518
)
519+
let javaKindDocs = renderJavaKindDocc()
488520
var classDecl: DeclSyntax =
489521
"""
490-
\(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr))
522+
\(raw: javaKindDocs)\(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr))
491523
\(raw: introducer) \(raw: swiftInnermostTypeName)\(raw: genericParameterClause)\(raw: inheritanceClause) {
492524
\(raw: members.map { $0.description }.joined(separator: "\n\n"))
493525
}

‎Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import XCTest
2222
class JavaKitMacroTests: XCTestCase {
2323
static let javaKitMacros: [String: any Macro.Type] = [
2424
"JavaClass": JavaClassMacro.self,
25+
"JavaRecord": JavaClassMacro.self,
2526
"JavaMethod": JavaMethodMacro.self,
2627
"JavaField": JavaFieldMacro.self,
2728
"JavaStaticMethod": JavaMethodMacro.self,
@@ -468,6 +469,64 @@ class JavaKitMacroTests: XCTestCase {
468469
)
469470
}
470471

472+
func testJavaRecord() throws {
473+
assertMacroExpansion(
474+
"""
475+
@JavaRecord("com.example.Point")
476+
open class Point: JavaObject {
477+
@JavaMethod
478+
@_nonoverride public convenience init(_ x: Int32, _ y: Int32, environment: JNIEnvironment? = nil)
479+
480+
@JavaMethod
481+
open func x() -> Int32
482+
}
483+
""",
484+
expandedSource: """
485+
486+
open class Point: JavaObject {
487+
@_nonoverride public convenience init(_ x: Int32, _ y: Int32, environment: JNIEnvironment? = nil) {
488+
let _environment = if let environment {
489+
environment
490+
} else {
491+
try! JavaVirtualMachine.shared().environment()
492+
}
493+
let javaThis = try! Self.dynamicJavaNewObjectInstance(in: _environment, arguments: x.self, y.self)
494+
self.init(javaThis: javaThis, environment: _environment)
495+
}
496+
open func x() -> Int32 {
497+
return {
498+
do {
499+
return try dynamicJavaMethodCall(methodName: "x", resultType: Int32.self)
500+
} catch {
501+
if let throwable = error as? Throwable {
502+
let sw = StringWriter()
503+
let pw = PrintWriter(sw)
504+
throwable.printStackTrace(pw)
505+
fatalError("Java call threw unhandled exception: \\(error)\\n\\(sw.toString())")
506+
}
507+
fatalError("Java call threw unhandled exception: \\(error)")
508+
}
509+
}()
510+
}
511+
512+
/// The full Java class name for this Swift type.
513+
open override class var fullJavaClassName: String {
514+
#if os(Android) && AndroidCoreLibraryDesugaring
515+
AndroidSupport.androidDesugarClassNameConversion(for: "com.example.Point")
516+
#else
517+
"com.example.Point"
518+
#endif
519+
}
520+
521+
public required init(javaHolder: JavaObjectHolder) {
522+
super.init(javaHolder: javaHolder)
523+
}
524+
}
525+
""",
526+
macros: Self.javaKitMacros
527+
)
528+
}
529+
471530
func testJavaGenericClassGenericMethodParameter() throws {
472531
assertMacroExpansion(
473532
"""

‎Tests/SwiftJavaToolLibTests/CompileJavaTool.swift‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,10 @@ struct CompileJavaTool {
7474
static func compileJava(_ sourceText: String) async throws -> Foundation.URL {
7575
// Java requires public class files to be named after the class
7676
let sourceFile: Foundation.URL
77-
if let match = sourceText.range(of: #"public\s+class\s+(\w+)"#, options: .regularExpression) {
77+
// Allow arbitrary modifiers between `public` and the decl introducer keyword.
78+
// The `-` is allowed because `non-sealed`.
79+
let publicTypeRegex = #"public\s+(?:[\w-]+\s+)*(?:class|record|interface|enum)\s+(\w+)"#
80+
if let match = sourceText.range(of: publicTypeRegex, options: .regularExpression) {
7881
let classNameRange = sourceText[match]
7982
let className = classNameRange.split(separator: " ").last.map(String.init) ?? "tmp_\(UUID().uuidString)"
8083
let dir = FileManager.default.temporaryDirectory

0 commit comments

Comments
 (0)