Skip to content
Open
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,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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"mode": "jni",
"memoryManagementMode": "allowGlobalAutomatic",
"failOnSkippedDeclarations": true,
"payloadlessEnumsAsJavaEnums": true
"payloadlessEnumsAsJavaEnums": true,
"optionalsAsNullable": true
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Boolean>`
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,
Expand Down
8 changes: 8 additions & 0 deletions Sources/SwiftJavaConfigurationShared/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?`
Expand Down
102 changes: 102 additions & 0 deletions Tests/JExtractSwiftTests/JNI/JNINullableOptionalsTests.swift
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> f(OptionalLong count) {"]
)
}
}
Loading