diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/OrderStatus.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/OrderStatus.swift new file mode 100644 index 000000000..2ea40ff58 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/OrderStatus.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Without associated values, so Kotlin gets a Java `enum` (`payloadlessEnumsAsJavaEnums`). +public enum OrderStatus: String, CaseIterable, Sendable { + case pending + case filled + case cancelled + + public var isFinal: Bool { self != .pending } +} + +public struct Order: Sendable { + public var id: String + public var status: OrderStatus + + public init(id: String, status: OrderStatus) { + self.id = id + self.status = status + } +} + +public enum OrderEvent: Sendable { + case changed(OrderStatus) +} + +public enum Orders { + public static func fill(_ status: OrderStatus) -> OrderStatus { + status == .pending ? .filled : status + } + + public static func all() -> [OrderStatus] { OrderStatus.allCases } + + public static func status(of id: String) -> OrderStatus? { id.isEmpty ? nil : .pending } + + public static func byID() -> [String: OrderStatus] { ["a": .filled] } + + public static func event(_ status: OrderStatus) -> OrderEvent { .changed(status) } + + public static func watch(_ onChange: @escaping @Sendable (OrderStatus) -> Void) { + onChange(.cancelled) + } + + public static func settle() async -> OrderStatus { .filled } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config index 62bcfc169..85ac54dd1 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config @@ -2,5 +2,6 @@ "javaPackage": "com.example.kotlin", "mode": "jni", "memoryManagementMode": "allowGlobalAutomatic", - "failOnSkippedDeclarations": true + "failOnSkippedDeclarations": true, + "payloadlessEnumsAsJavaEnums": true } diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/JavaEnumsTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/JavaEnumsTest.kt new file mode 100644 index 000000000..e63979fba --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/JavaEnumsTest.kt @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// 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.kotlin + +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class JavaEnumsTest { + @Test + fun exhaustiveWhen() { + val label = when (Orders.fill(OrderStatus.PENDING)) { + OrderStatus.PENDING -> "pending" + OrderStatus.FILLED -> "filled" + OrderStatus.CANCELLED -> "cancelled" + } + assertEquals("filled", label) + } + + @Test + fun constantsAreTheValuesSwiftReturns() { + assertSame(OrderStatus.FILLED, Orders.fill(OrderStatus.PENDING)) + assertEquals(OrderStatus.entries, Orders.all().toList()) + assertEquals("cancelled", OrderStatus.CANCELLED.rawValue) + assertTrue(OrderStatus.CANCELLED.isFinal) + } + + @Test + fun everywhereAValueCrosses() = runBlocking { + assertSame(OrderStatus.PENDING, Orders.status("a").get()) + assertSame(OrderStatus.FILLED, Orders.byID()["a"]) + assertSame(OrderStatus.CANCELLED, Order.init("a", OrderStatus.CANCELLED).status) + assertSame(OrderStatus.FILLED, (Orders.event(OrderStatus.FILLED).case as OrderEvent.Case.Changed).arg0) + var seen: OrderStatus? = null + Orders.watch { seen = it } + assertSame(OrderStatus.CANCELLED, seen) + assertSame(OrderStatus.FILLED, Orders.settle().await()) + } + + @Test + fun manyRoundTrips() { + repeat(10_000) { assertSame(OrderStatus.FILLED, Orders.fill(OrderStatus.FILLED)) } + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 71dbed448..6b072c619 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -312,6 +312,10 @@ extension JNISwift2JavaGenerator { private func printConcreteType(_ printer: inout JavaPrinter, _ decl: ExtractedNominalType) { printNominal(&printer, decl) { printer in + if self.isJavaEnum(decl) { + printer.print(decl.cases.map { $0.name.uppercased() }.joined(separator: ", ") + ";") + printer.println() + } printer.print( """ static final java.lang.String LIB_NAME = "\(config.nativeLibraryName ?? swiftModuleName)"; @@ -365,44 +369,48 @@ extension JNISwift2JavaGenerator { } let javaName = decl.effectiveJavaSimpleName let superclass = self.extractedSuperclass(of: decl) - printDesignatedConstructor( - &printer, - javaName: javaName, - pointerParams: swiftPointerParams, - isExtendable: self.isExtendableClass(decl), - hasSuperclass: superclass != nil - ) - printer.println() - let genericClause = decl.javaGenericClause - printWrapMemoryAddressUnsafeFactory( - &printer, - javaName: javaName, - genericClause: genericClause, - pointerParams: swiftPointerParams, - docSummary: "Assume that the passed {@code long} represents a memory address of a {@link \(javaName)}.", - subclasses: self.extractedSubclasses(of: decl) - ) + if self.isJavaEnum(decl) { + printJavaEnumSwiftValues(&printer, decl) + } else { + printDesignatedConstructor( + &printer, + javaName: javaName, + pointerParams: swiftPointerParams, + isExtendable: self.isExtendableClass(decl), + hasSuperclass: superclass != nil + ) + printer.println() + let genericClause = decl.javaGenericClause + printWrapMemoryAddressUnsafeFactory( + &printer, + javaName: javaName, + genericClause: genericClause, + pointerParams: swiftPointerParams, + docSummary: "Assume that the passed {@code long} represents a memory address of a {@link \(javaName)}.", + subclasses: self.extractedSubclasses(of: decl) + ) - // A subclass inherits the pointer and cleanup from the root of its hierarchy. - if superclass == nil { - printer.print( - """ - /** Pointer to the "self". */ - private final long selfPointer; + // A subclass inherits the pointer and cleanup from the root of its hierarchy. + if superclass == nil { + printer.print( + """ + /** Pointer to the "self". */ + private final long selfPointer; - /** Tracks whether this instance has been destroyed; doubles as the destroyed-state holder. */ - private final SwiftInstanceCleanup $cleanup; + /** Tracks whether this instance has been destroyed; doubles as the destroyed-state holder. */ + private final SwiftInstanceCleanup $cleanup; - public long $memoryAddress() { - return this.selfPointer; - } + public long $memoryAddress() { + return this.selfPointer; + } - @Override - public SwiftInstanceCleanup $cleanup() { - return $cleanup; - } - """ - ) + @Override + public SwiftInstanceCleanup $cleanup() { + return $cleanup; + } + """ + ) + } } if isEffectivelyGeneric { @@ -545,21 +553,24 @@ extension JNISwift2JavaGenerator { /// Prints common Swift object methods such as `equals`, `hashCode` etc. private func printSwiftInstanceObjectMethods(_ printer: inout JavaPrinter, _ decl: ExtractedNominalType) { - printer.print( - """ - public boolean equals(Object obj) { - if (obj instanceof JNISwiftInstance rhs) { - return SwiftObjects.equals(this.$memoryAddress(), this.$typeMetadataAddress(), rhs.$memoryAddress(), rhs.$typeMetadataAddress()); + // A Java enum has one constant per case, so its final `equals` and `hashCode` already match Swift's. + if !self.isJavaEnum(decl) { + printer.print( + """ + public boolean equals(Object obj) { + if (obj instanceof JNISwiftInstance rhs) { + return SwiftObjects.equals(this.$memoryAddress(), this.$typeMetadataAddress(), rhs.$memoryAddress(), rhs.$typeMetadataAddress()); + } + return false; } - return false; - } - public int hashCode() { - return SwiftObjects.hashCode(this.$memoryAddress(), this.$typeMetadataAddress()); - } + public int hashCode() { + return SwiftObjects.hashCode(this.$memoryAddress(), this.$typeMetadataAddress()); + } - """ - ) + """ + ) + } // Error types inherit `Throwable.toString()`, which prints the class name followed by the Swift description. if !self.isSwiftErrorType(decl) { printer.print( @@ -634,7 +645,8 @@ extension JNISwift2JavaGenerator { if decl.parent != nil { modifiers.append("static") } - if !self.isExtendableClass(decl) { + let isJavaEnum = self.isJavaEnum(decl) + if !self.isExtendableClass(decl) && !isJavaEnum { modifiers.append("final") } let isErrorType = self.isSwiftErrorType(decl) @@ -659,12 +671,64 @@ extension JNISwift2JavaGenerator { // Specialized types are concrete — no generic clause on the Java side let genericClause = decl.javaGenericClause printer.printBraceBlock( - "\(modifiers.joined(separator: " ")) class \(decl.effectiveJavaSimpleName)\(genericClause)\(extendsClause)\(implementsClause)" + "\(modifiers.joined(separator: " ")) \(isJavaEnum ? "enum" : "class") \(decl.effectiveJavaSimpleName)\(genericClause)\(extendsClause)\(implementsClause)" ) { printer in body(&printer) } } + /// Whether `decl` is printed as a Java `enum` (`payloadlessEnumsAsJavaEnums`): a non-generic, + /// non-error Swift enum whose cases have no associated values. + func isJavaEnum(_ decl: ExtractedNominalType) -> Bool { + config.effectivePayloadlessEnumsAsJavaEnums && decl.swiftNominal.kind == .enum && !decl.swiftNominal.isGeneric + && !decl.cases.isEmpty && decl.cases.allSatisfy(\.parameters.isEmpty) && !self.isSwiftErrorType(decl) + } + + /// A Java enum constant keeps one Swift value of its case for the life of the process, so passing it + /// to Swift needs no allocation. A Swift value returned to Java is mapped to its case's constant and + /// destroyed. + private func printJavaEnumSwiftValues(_ printer: inout JavaPrinter, _ decl: ExtractedNominalType) { + let javaName = decl.effectiveJavaSimpleName + let caseValues = decl.cases.compactMap { self.translatedDecl(for: $0.caseFunction)?.nativeFunctionName } + printer.print( + """ + /** The Swift value of each case, created on first use and never destroyed. */ + private static final class $SwiftValues { + static final long[] ADDRESSES = { \(caseValues.map { "\(javaName).\($0)()" }.joined(separator: ", ")) }; + } + + private static final SwiftInstanceCleanup $permanent = new SwiftInstanceCleanup() { + public boolean isDestroyed() { return false; } + public void run() {} + }; + + /** + * The constant for the case of the Swift value at {@code selfPointer}, which is destroyed: the + * constant keeps its own Swift value. + */ + public static \(javaName) wrapMemoryAddressUnsafe(long selfPointer, SwiftArena swiftArena) { + return wrapMemoryAddressUnsafe(selfPointer); + } + + public static \(javaName) wrapMemoryAddressUnsafe(long selfPointer) { + long typeMetadataAddress = \(javaName).$typeMetadataAddressDowncall(); + \(javaName) value = values()[SwiftObjects.getRawDiscriminator(selfPointer, typeMetadataAddress)]; + SwiftObjects.destroy(selfPointer, typeMetadataAddress); + return value; + } + + public long $memoryAddress() { + return $SwiftValues.ADDRESSES[ordinal()]; + } + + @Override + public SwiftInstanceCleanup $cleanup() { + return $permanent; + } + """ + ) + } + private func printModuleClass(_ printer: inout JavaPrinter, body: (inout JavaPrinter) -> Void) { printer.printBraceBlock("public final class \(swiftModuleName)") { printer in body(&printer) diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index 105c19b04..ebbd7322f 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -188,6 +188,14 @@ public struct Configuration: Codable { failOnSkippedDeclarations ?? false } + /// If true, Swift enums whose cases have no associated values are generated as Java `enum`s, + /// with one constant per case, instead of classes wrapping a Swift value. Only supported in + /// `jni` mode. + public var payloadlessEnumsAsJavaEnums: Bool? + public var effectivePayloadlessEnumsAsJavaEnums: Bool { + payloadlessEnumsAsJavaEnums ?? false + } + /// If specified, JExtract will output to this file a list of paths to all generated Java source files. public var generatedJavaSourcesListFileOutput: String? diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md index d88c717ad..905337338 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -256,6 +256,17 @@ extract or translate, and lists each one. Skipped declarations are always report --- +#### payloadlessEnumsAsJavaEnums + +- **Type:** `Bool?` +- **Default:** `false` + +If true, Swift enums whose cases have no associated values are generated as Java `enum`s, +with one constant per case, instead of classes wrapping a Swift value. Only supported in +`jni` mode. + +--- + #### generatedJavaSourcesListFileOutput - **Type:** `String?` diff --git a/Tests/JExtractSwiftTests/JNI/JNIJavaEnumTests.swift b/Tests/JExtractSwiftTests/JNI/JNIJavaEnumTests.swift new file mode 100644 index 000000000..d1a71dd32 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIJavaEnumTests.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// +// 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 JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +/// With `payloadlessEnumsAsJavaEnums`, enums without associated values become Java `enum`s. +@Suite +struct JNIJavaEnumTests { + var config: Configuration { + var config = Configuration() + config.payloadlessEnumsAsJavaEnums = true + return config + } + + let source = """ + public enum Phase { + case idle + case loading + } + """ + + @Test + func casesBecomeConstants() throws { + try assertOutput( + input: source, + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public enum Phase implements JNISwiftInstance {", + "IDLE, LOADING;", + "static final long[] ADDRESSES = { Phase.$idle(), Phase.$loading() };", + """ + public static Phase wrapMemoryAddressUnsafe(long selfPointer) { + long typeMetadataAddress = Phase.$typeMetadataAddressDowncall(); + Phase value = values()[SwiftObjects.getRawDiscriminator(selfPointer, typeMetadataAddress)]; + SwiftObjects.destroy(selfPointer, typeMetadataAddress); + return value; + } + """, + """ + public long $memoryAddress() { + return $SwiftValues.ADDRESSES[ordinal()]; + } + """, + ], + notExpectedChunks: [ + "public boolean equals(Object obj)", + "public int hashCode()", + "private Phase(long selfPointer", + ] + ) + } + + @Test + func classesWithoutTheOption() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: ["public final class Phase implements JNISwiftInstance {"] + ) + } + + @Test(arguments: [ + "public enum Phase { case idle; case failed(String) }", + "public enum Phase: Error { case idle }", + "public enum Phase {}", + ]) + func otherEnumsStayClasses(input: String) throws { + try assertOutput( + input: input, + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: ["class Phase"], + notExpectedChunks: ["enum Phase implements"] + ) + } +}