From 86fe2ce1e1c52eb97e89b1db9a650ade4acb95d1 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 18:35:53 +0100 Subject: [PATCH 1/2] JNI: optionalsAsNullable passes optionals as nullable Java types With the new `optionalsAsNullable` option, a declaration's optional parameters and results use the wrapped Java type, boxed, with `null` for `nil`, instead of `Optional`/`OptionalLong`/`OptionalInt`/`OptionalDouble`. It's a Java-side layer over the existing conversions: a parameter builds the `Optional` they expect from the nullable argument, and a result unwraps their `Optional` once into a typed local. Native methods and Swift thunks are unchanged. Applied in `translate(_ decl:)` to the declaration's own optionals only; closure and collection element types keep `Optional`, and enum `getAsX()` wraps nullable associated values before mapping them. Off by default: it changes the Java API. Co-Authored-By: Claude Opus 5.5 --- ...t2JavaGenerator+JavaBindingsPrinting.swift | 10 +- ...ISwift2JavaGenerator+JavaTranslation.swift | 71 ++++++++++++ .../Configuration.swift | 8 ++ .../Documentation.docc/SwiftJavaConfigFile.md | 11 ++ .../JNI/JNINullableOptionalsTests.swift | 102 ++++++++++++++++++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 Tests/JExtractSwiftTests/JNI/JNINullableOptionalsTests.swift diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 6b072c619..627368cd4 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -896,8 +896,16 @@ extension JNISwift2JavaGenerator { }.joined(separator: .comma) } let translatedResult = getAsCaseFunction.translatedFunctionSignature.result + let associatedValues = JavaNativeConversionStep.placeToVar( + translatedResult.conversion, + name: "associatedValues$", + type: translatedResult.javaType + ) + // With `optionalsAsNullable` the associated values are `null` when it's another case. getAsCaseFunction.translatedFunctionSignature.result.conversion = .method( - .placeToVar(translatedResult.conversion, name: "associatedValues$", type: translatedResult.javaType), + config.effectiveOptionalsAsNullable + ? .method(.constant("java.util.Optional"), function: "ofNullable", arguments: [associatedValues]) + : associatedValues, function: "map", arguments: [ .lambda( diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 4ba08fa11..fbae25922 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -243,6 +243,15 @@ extension JNISwift2JavaGenerator { methodName: javaName, parentName: parentName, ) + // The declaration's own optionals only: closures and other nested signatures keep `Optional`. + if config.effectiveOptionalsAsNullable { + for (idx, parameter) in resultSignature.parameters.enumerated() where Self.isOptional(parameter.type) { + translatedFunctionSignature.parameters[idx] = nullable(translatedFunctionSignature.parameters[idx]) + } + if Self.isOptional(resultSignature.result.type) { + translatedFunctionSignature.result = nullable(translatedFunctionSignature.result, resultName: "result") + } + } // Java -> Java (native) var nativeFunctionSignature = try nativeTranslation.translate( functionSignature: resultSignature, @@ -934,6 +943,68 @@ extension JNISwift2JavaGenerator { ) } + static func isOptional(_ type: SwiftType) -> Bool { + if case .nominal(let nominal) = type, nominal.nominalTypeDecl.knownTypeKind == .optional { + return true + } + return false + } + + /// The wrapped Java type of a Java optional type, boxed, with the method that unwraps a present value. + private func nullableType(ofOptional type: JavaType) -> (type: JavaType, getter: String)? { + guard case .class(_, let name, let typeParameters) = type else { + return nil + } + switch name { + case "OptionalLong": return (.class(package: "java.lang", name: "Long"), "getAsLong") + case "OptionalInt": return (.class(package: "java.lang", name: "Integer"), "getAsInt") + case "OptionalDouble": return (.class(package: "java.lang", name: "Double"), "getAsDouble") + case "Optional" where typeParameters.count == 1: return (typeParameters[0], "get") + // Primitive optionals spelled as one class name, such as `Optional` + case _ where name.hasPrefix("Optional<") && name.hasSuffix(">"): + return (JavaType(className: name.dropFirst("Optional<".count).dropLast()), "get") + default: return nil + } + } + + /// With `optionalsAsNullable`, an optional parameter takes the wrapped type, `null` for `nil`, and + /// makes the `Optional` the translated conversion expects. + func nullable(_ parameter: TranslatedParameter) -> TranslatedParameter { + guard let (wrapped, _) = nullableType(ofOptional: parameter.parameter.type.javaType), + case .class(_, let optionalClass, _) = parameter.parameter.type.javaType + else { + return parameter + } + let name = parameter.parameter.name + let optional = + switch optionalClass { + case "OptionalLong", "OptionalInt", "OptionalDouble": + "(\(name) == null ? \(optionalClass).empty() : \(optionalClass).of(\(name)))" + default: + "Optional.ofNullable(\(name))" + } + return TranslatedParameter( + parameter: JavaParameter(name: name, type: wrapped, annotations: parameter.parameter.annotations), + conversion: .replacingPlaceholder(parameter.conversion, placeholder: optional) + ) + } + + /// With `optionalsAsNullable`, an optional result is its wrapped value, or `null` for `nil`. + func nullable(_ result: TranslatedResult, resultName: String) -> TranslatedResult { + guard let (wrapped, getter) = nullableType(ofOptional: result.javaType) else { + return result + } + let optional = "\(resultName)$optional" + var nullable = result + nullable.javaType = wrapped + nullable.conversion = .ternary( + .method(.placeToVar(result.conversion, name: optional, type: result.javaType), function: "isPresent"), + thenExp: .method(.constant(optional), function: getter), + elseExp: .constant("null") + ) + return nullable + } + func translateOptionalParameter( wrappedType swiftType: SwiftType, parameterName: String, diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index ebbd7322f..1e8cb1ed4 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -196,6 +196,14 @@ public struct Configuration: Codable { payloadlessEnumsAsJavaEnums ?? false } + /// If true, optional parameters and results of functions and properties use the wrapped Java type, + /// with `null` for `nil`, instead of `Optional`, `OptionalLong`, `OptionalInt` or `OptionalDouble`. + /// Only supported in `jni` mode. + public var optionalsAsNullable: Bool? + public var effectiveOptionalsAsNullable: Bool { + optionalsAsNullable ?? 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 905337338..c34a1fd8e 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -267,6 +267,17 @@ with one constant per case, instead of classes wrapping a Swift value. Only supp --- +#### optionalsAsNullable + +- **Type:** `Bool?` +- **Default:** `false` + +If true, optional parameters and results of functions and properties use the wrapped Java type, +with `null` for `nil`, instead of `Optional`, `OptionalLong`, `OptionalInt` or `OptionalDouble`. +Only supported in `jni` mode. + +--- + #### generatedJavaSourcesListFileOutput - **Type:** `String?` diff --git a/Tests/JExtractSwiftTests/JNI/JNINullableOptionalsTests.swift b/Tests/JExtractSwiftTests/JNI/JNINullableOptionalsTests.swift new file mode 100644 index 000000000..ca21a2936 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNINullableOptionalsTests.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// +// 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 `optionalsAsNullable`, a declaration's optionals are its wrapped Java types, `null` for `nil`. +@Suite +struct JNINullableOptionalsTests { + var config: Configuration { + var config = Configuration() + config.optionalsAsNullable = true + return config + } + + @Test + func primitiveParameterAndStringResult() throws { + try assertOutput( + input: "public func f(count: Int64?) -> String? {}", + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static String f(java.lang.Long count) {", + "(count == null ? OptionalLong.empty() : OptionalLong.of(count))", + "Optional result$optional = ", + "return (result$optional.isPresent()) ? result$optional.get() : null;", + // The native method is unchanged. + "private static native java.lang.String $f(byte count_discriminator, long count_value, byte[] result_discriminator$);", + ] + ) + } + + @Test + func primitiveResult() throws { + try assertOutput( + input: "public func f() -> Int64? {}", + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static java.lang.Long f() {", + "return (result$optional.isPresent()) ? result$optional.getAsLong() : null;", + ] + ) + } + + @Test + func jextractedParameterAndResult() throws { + try assertOutput( + input: """ + public struct Tag {} + public func f(tag: Tag?) -> Tag? {} + """, + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static Tag f(Tag tag, SwiftArena swiftArena) {", + "Optional.ofNullable(tag).map(Tag::$memoryAddress).orElse(0L)", + ] + ) + } + + @Test + func escapingClosureParametersKeepOptional() throws { + try assertOutput( + input: "public func f(_ body: @escaping (Int64?) -> Void) {}", + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: ["void apply(OptionalLong _0);"] + ) + } + + @Test + func optionalWithoutTheOption() throws { + try assertOutput( + input: "public func f(count: Int64?) -> String? {}", + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: ["public static Optional f(OptionalLong count) {"] + ) + } +} From 6f34404ebccba7168d383954685c4dadb3f3812c Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 18:35:53 +0100 Subject: [PATCH 2/2] Samples: Swift optionals as Kotlin nulls Co-Authored-By: Claude Opus 5.5 --- .../Sources/KotlinSampleLibrary/Quotes.swift | 44 +++++++++++++++++ .../KotlinSampleLibrary/swift-java.config | 3 +- .../com/example/kotlin/JavaEnumsTest.kt | 2 +- .../com/example/kotlin/NullablesTest.kt | 48 +++++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Quotes.swift create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/NullablesTest.kt diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Quotes.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Quotes.swift new file mode 100644 index 000000000..31ad8f837 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Quotes.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Optionals are nullable in Kotlin (`optionalsAsNullable`). +public struct Quote: Sendable { + public var symbol: String + public var price: Double? + public var note: String? + + public init(symbol: String, price: Double? = nil, note: String? = nil) { + self.symbol = symbol + self.price = price + self.note = note + } +} + +public enum Quotes { + public static func last(_ symbol: String) -> Quote? { + symbol.isEmpty ? nil : Quote(symbol: symbol, price: 1.5) + } + + public static func volume(_ symbol: String) -> Int64? { + symbol == "a" ? 10 : nil + } + + public static func describe(_ price: Double?, _ count: Int64?) -> String { + "\(price.map { "\($0)" } ?? "-") \(count.map { "\($0)" } ?? "-")" + } + + public static func invert(_ flag: Bool?) -> Bool? { + flag.map { !$0 } + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config index 85ac54dd1..d1e4b048f 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config @@ -3,5 +3,6 @@ "mode": "jni", "memoryManagementMode": "allowGlobalAutomatic", "failOnSkippedDeclarations": true, - "payloadlessEnumsAsJavaEnums": true + "payloadlessEnumsAsJavaEnums": true, + "optionalsAsNullable": 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 index e63979fba..c1c35ec50 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/JavaEnumsTest.kt +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/JavaEnumsTest.kt @@ -42,7 +42,7 @@ class JavaEnumsTest { @Test fun everywhereAValueCrosses() = runBlocking { - assertSame(OrderStatus.PENDING, Orders.status("a").get()) + assertSame(OrderStatus.PENDING, Orders.status("a")) 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) diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/NullablesTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/NullablesTest.kt new file mode 100644 index 000000000..9fc6caffe --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/NullablesTest.kt @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NullablesTest { + @Test + fun nullableResults() { + val quote: Quote? = Quotes.last("a") + assertEquals(1.5, quote?.price) + assertNull(quote?.note) + assertNull(Quotes.last("")) + assertEquals(10L, Quotes.volume("a")) + assertNull(Quotes.volume("b")) + } + + @Test + fun nullableParameters() { + assertEquals("1.5 -", Quotes.describe(1.5, null)) + assertEquals("- 3", Quotes.describe(null, 3L)) + assertEquals(false, Quotes.invert(true)) + assertNull(Quotes.invert(null)) + } + + @Test + fun nullableProperties() { + val quote = Quote.init("b") + quote.note = "watch" + assertEquals("watch", quote.note) + quote.note = null + assertNull(quote.note) + } +}