diff --git a/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Point.java b/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Point.java new file mode 100644 index 000000000..b2553023d --- /dev/null +++ b/Samples/JavaKitSampleApp/Sources/JavaKitExample/com/example/swift/Point.java @@ -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 @JavaRecord support. + */ +public record Point(int x, int y) {} diff --git a/Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config b/Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config index 1c8b3c3fc..d9e8e090d 100644 --- a/Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config +++ b/Samples/JavaKitSampleApp/Sources/JavaKitExample/swift-java.config @@ -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" } } diff --git a/Samples/JavaKitSampleApp/Tests/JavaKitExampleTests/JavaRecordRuntimeTests.swift b/Samples/JavaKitSampleApp/Tests/JavaKitExampleTests/JavaRecordRuntimeTests.swift new file mode 100644 index 000000000..4484bd118 --- /dev/null +++ b/Samples/JavaKitSampleApp/Tests/JavaKitExampleTests/JavaRecordRuntimeTests.swift @@ -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]") + } +} diff --git a/Sources/SwiftJava/Macros.swift b/Sources/SwiftJava/Macros.swift index 756fb59f6..20a911233 100644 --- a/Sources/SwiftJava/Macros.swift +++ b/Sources/SwiftJava/Macros.swift @@ -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 diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md index a76787756..f30149069 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md @@ -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. diff --git a/Sources/SwiftJavaMacros/GenerationMode.swift b/Sources/SwiftJavaMacros/GenerationMode.swift index c9a805de2..b753e9b08 100644 --- a/Sources/SwiftJavaMacros/GenerationMode.swift +++ b/Sources/SwiftJavaMacros/GenerationMode.swift @@ -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": diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index ded0f80e8..bbb796da7 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -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 { @@ -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")) } diff --git a/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift b/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift index d64a2fc7b..dad2b2db6 100644 --- a/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift +++ b/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift @@ -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, @@ -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( """ diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift b/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift index fff433baa..1fde8202a 100644 --- a/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift +++ b/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift @@ -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