Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift.org project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

package com.example.swift;

/**
* Java 16+ record used to smoke-test wrap-java's <code>@JavaRecord</code> support.
*/
public record Point(int x, int y) {}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"com.example.swift.HelloSubclass" : "HelloSubclass",
"com.example.swift.HelloJavaKitArrays" : "HelloJavaKitArrays",
"com.example.swift.JavaKitSampleMain" : "JavaKitSampleMain",
"com.example.swift.Point" : "Point",
"com.example.swift.ThreadSafeHelperClass" : "ThreadSafeHelperClass"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift.org project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import JavaKitExample
import SwiftJava
import Testing

@Suite
struct JavaRecordRuntimeTests {

let jvm = try JavaKitSampleJVM.shared

@Test
func constructAndReadComponents() throws {
let env = try jvm.environment()

let p = Point(3, 4, environment: env)
#expect(p.x() == 3)
#expect(p.y() == 4)
}

@Test
func equalityAndHashUseRecordContract() throws {
let env = try jvm.environment()

let a = Point(1, 2, environment: env)
let b = Point(1, 2, environment: env)
let c = Point(1, 3, environment: env)

#expect(a.equals(b.as(JavaObject.self)) == true)
#expect(a.equals(c.as(JavaObject.self)) == false)
#expect(a.hashCode() == b.hashCode())
}

@Test
func toStringMatchesRecordCanonicalForm() throws {
let env = try jvm.environment()

let p = Point(7, 9, environment: env)
// Java record toString is `TypeName[comp1=v1, comp2=v2]`.
#expect(p.toString() == "Point[x=7, y=9]")
}
}
40 changes: 40 additions & 0 deletions Sources/SwiftJava/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,46 @@ public macro JavaClass(
implements: (any AnyJavaObject.Type)?...
) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro")

/// Attached macro that declares that a particular Swift type is a wrapper around a Java `record`.
///
/// This behaves identically to ``JavaClass`` today (records are wrapped as
/// classes on the Swift side, exposing the canonical constructor and the
/// component accessor methods)
///
/// Usage:
///
/// ```swift
/// @JavaRecord("com.example.Point")
/// open class Point: JavaObject {
/// @JavaMethod
/// @_nonoverride public convenience init(_ x: Int32, _ y: Int32, environment: JNIEnvironment? = nil)
///
/// @JavaMethod open func x() -> Int32
/// @JavaMethod open func y() -> Int32
/// }
/// ```
///
/// Would correspond to a Java record like this:
/// ```java
/// package com.example;
///
/// public record Point(int x, int y) {}
/// ```
@attached(
member,
names: named(fullJavaClassName),
named(javaHolder),
named(init(javaHolder:)),
named(JavaSuperclass),
named(`as`)
)
@attached(extension, conformances: AnyJavaObject)
public macro JavaRecord(
_ fullClassName: String,
extends: (any AnyJavaObject.Type)? = nil,
implements: (any AnyJavaObject.Type)?...
) = #externalMacro(module: "SwiftJavaMacros", type: "JavaClassMacro")

/// Attached macro that declares that a particular `struct` type is a wrapper around a Java interface.
///
/// Use this macro to describe a type that was implemented as an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ Java `native` functions. SwiftJava simplifies the type conversions
|----------------------------------------|---------------|
| Java `class` | ✅ |
| Java class inheritance | ✅ |
| Java methods: `static`, member | ✅ `@JavaMethod` |
| Java `abstract class` | TODO |
| Java `enum` | ❌ |
| Java methods: `static`, member | ✅ `@JavaMethod` |
| Java `record` (Java 16+) | ✅ `@JavaRecord` |
| Java `sealed class` / `sealed interface` (Java 17+) | 🟡 recognized, but missing special handling of `permits` list |
| **This list is very work in progress** | |


### JExtract – calling Swift from Java

SwiftJava's `swift-java jextract` tool automates generating Java bindings from Swift sources.
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftJavaMacros/GenerationMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ enum GenerationMode {
/// Determine the mode for Java class generation based on an attribute.
init?(attribute: AttributeSyntax) {
switch attribute.attributeName.trimmedDescription {
case "JavaClass", "JavaInterface":
case "JavaClass", "JavaInterface", "JavaRecord":
self = .importFromJava

case "ExportToJavaClass":
Expand Down
36 changes: 34 additions & 2 deletions Sources/SwiftJavaToolLib/JavaClassTranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,28 @@ extension JavaClassTranslator {
return allDecls
}

/// Render additional documentation about the Java type, e.g. if it was a `sealed` type, etc.
private func renderJavaKindDocc() -> String {
var lines: [String] = []
if javaClass.isRecord() {
lines.append("/// Java record: `\(javaClass.getName())`")
}
if javaClass.isSealed() {
let kind = javaClass.isInterface() ? "sealed interface" : "sealed class"
let permits = javaClass.getPermittedSubclasses().compactMap { $0?.getName() }
if permits.isEmpty {
lines.append("/// Java `\(kind)`")
} else {
lines.append("/// Java `\(kind)`, permits: \(permits.map({ "`\($0)`" }).joined(separator: ", "))")
}
log.info("Wrap Java \(kind): `\(javaClass.getName())`, permits: \(permits.map({ "`\($0)`" }).joined(separator: ", ")))")
}
if lines.isEmpty {
return ""
}
return lines.joined(separator: "\n") + "\n"
}

/// Render the declaration for the main part of the Java class, which
/// includes the constructors, non-static fields, and non-static methods.
private func renderPrimaryType() -> DeclSyntax {
Expand Down Expand Up @@ -478,16 +500,26 @@ extension JavaClassTranslator {
}

// Emit the struct declaration describing the java class.
let classOrInterface: String = isInterface ? "JavaInterface" : "JavaClass"
// Records are wrapped like classes today, but tagged with @JavaRecord so the
// "was a record" signal survives into the generated Swift and downstream tools.
let classOrInterface: String =
if isInterface {
"JavaInterface"
} else if javaClass.isRecord() {
"JavaRecord"
} else {
"JavaClass"
}
let introducer = translateAsClass ? "open class" : "public struct"
let classAvailableAttributes = swiftAvailableAttributes(
from: annotations,
runtimeInvisibleAnnotations: self.runtimeInvisibleAnnotations.classAnnotations,
javaClass: javaClass
)
let javaKindDocs = renderJavaKindDocc()
var classDecl: DeclSyntax =
"""
\(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr))
\(raw: javaKindDocs)\(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr))
\(raw: introducer) \(raw: swiftInnermostTypeName)\(raw: genericParameterClause)\(raw: inheritanceClause) {
\(raw: members.map { $0.description }.joined(separator: "\n\n"))
}
Expand Down
59 changes: 59 additions & 0 deletions Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import XCTest
class JavaKitMacroTests: XCTestCase {
static let javaKitMacros: [String: any Macro.Type] = [
"JavaClass": JavaClassMacro.self,
"JavaRecord": JavaClassMacro.self,
"JavaMethod": JavaMethodMacro.self,
"JavaField": JavaFieldMacro.self,
"JavaStaticMethod": JavaMethodMacro.self,
Expand Down Expand Up @@ -468,6 +469,64 @@ class JavaKitMacroTests: XCTestCase {
)
}

func testJavaRecord() throws {
assertMacroExpansion(
"""
@JavaRecord("com.example.Point")
open class Point: JavaObject {
@JavaMethod
@_nonoverride public convenience init(_ x: Int32, _ y: Int32, environment: JNIEnvironment? = nil)

@JavaMethod
open func x() -> Int32
}
""",
expandedSource: """

open class Point: JavaObject {
@_nonoverride public convenience init(_ x: Int32, _ y: Int32, environment: JNIEnvironment? = nil) {
let _environment = if let environment {
environment
} else {
try! JavaVirtualMachine.shared().environment()
}
let javaThis = try! Self.dynamicJavaNewObjectInstance(in: _environment, arguments: x.self, y.self)
self.init(javaThis: javaThis, environment: _environment)
}
open func x() -> Int32 {
return {
do {
return try dynamicJavaMethodCall(methodName: "x", resultType: Int32.self)
} catch {
if let throwable = error as? Throwable {
let sw = StringWriter()
let pw = PrintWriter(sw)
throwable.printStackTrace(pw)
fatalError("Java call threw unhandled exception: \\(error)\\n\\(sw.toString())")
}
fatalError("Java call threw unhandled exception: \\(error)")
}
}()
}

/// The full Java class name for this Swift type.
open override class var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
AndroidSupport.androidDesugarClassNameConversion(for: "com.example.Point")
#else
"com.example.Point"
#endif
}

public required init(javaHolder: JavaObjectHolder) {
super.init(javaHolder: javaHolder)
}
}
""",
macros: Self.javaKitMacros
)
}

func testJavaGenericClassGenericMethodParameter() throws {
assertMacroExpansion(
"""
Expand Down
5 changes: 4 additions & 1 deletion Tests/SwiftJavaToolLibTests/CompileJavaTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ struct CompileJavaTool {
static func compileJava(_ sourceText: String) async throws -> Foundation.URL {
// Java requires public class files to be named after the class
let sourceFile: Foundation.URL
if let match = sourceText.range(of: #"public\s+class\s+(\w+)"#, options: .regularExpression) {
// Allow arbitrary modifiers between `public` and the decl introducer keyword.
// The `-` is allowed because `non-sealed`.
let publicTypeRegex = #"public\s+(?:[\w-]+\s+)*(?:class|record|interface|enum)\s+(\w+)"#
if let match = sourceText.range(of: publicTypeRegex, options: .regularExpression) {
let classNameRange = sourceText[match]
let className = classNameRange.split(separator: " ").last.map(String.init) ?? "tmp_\(UUID().uuidString)"
let dir = FileManager.default.temporaryDirectory
Expand Down
Loading