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
Expand Up @@ -4,5 +4,7 @@
"memoryManagementMode": "allowGlobalAutomatic",
"failOnSkippedDeclarations": true,
"payloadlessEnumsAsJavaEnums": true,
"optionalsAsNullable": true
"optionalsAsNullable": true,
"nullableAnnotation": "org.jetbrains.annotations.Nullable",
"nonNullAnnotation": "org.jetbrains.annotations.NotNull"
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,14 @@ class NullablesTest {
quote.note = null
assertNull(quote.note)
}

// With `nullableAnnotation` and `nonNullAnnotation`, Kotlin infers `Quote?` and `String`, not `Quote!` and `String!`.
@Test
fun annotatedTypes() {
val quote = Quotes.last("a")
val price: Double = quote?.price ?: 0.0
assertEquals(1.5, price)
val text: String = Quotes.describe(null, 3L)
assertEquals("- 3", text)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,10 @@ extension JNISwift2JavaGenerator {
for i in JNISwift2JavaGenerator.defaultJavaImports {
printer.print("import \(i);")
}
// A single-type import wins over `org.swift.swiftkit.core.annotations.*`.
for annotation in [config.nullableAnnotation, config.nonNullAnnotation].compactMap({ $0 }) {
printer.print("import \(annotation);")
}
printer.print("")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,30 @@ extension JNISwift2JavaGenerator {
)
}

// Kotlin's view of the declaration's own parameters and result, after every conversion above.
let resultIsNullable =
config.effectiveOptionalsAsNullable && Self.isOptional(decl.functionSignature.result.type)
&& streamElementType == nil
&& !(decl.functionSignature.isAsync || decl.functionSignature.isIsolated || decl.functionSignature.isImplicitlyAsync)
if let annotation = nullabilityAnnotation(translatedFunctionSignature.result.javaType, nullable: resultIsNullable) {
translatedFunctionSignature.result.annotations.append(annotation)
}
for (idx, parameter) in decl.functionSignature.parameters.enumerated() {
let nullable = config.effectiveOptionalsAsNullable && Self.isOptional(parameter.type)
let translated = translatedFunctionSignature.parameters[idx]
let javaType = translated.parameter.type
if !javaType.isPrimitive, let annotation = nullabilityAnnotation(javaType.javaType, nullable: nullable) {
translatedFunctionSignature.parameters[idx] = TranslatedParameter(
parameter: JavaParameter(
name: translated.parameter.name,
type: javaType,
annotations: translated.parameter.annotations + [annotation]
),
conversion: translated.conversion
)
}
}

var thrownJavaType: JavaType?
if case .nominal(let thrown) = decl.functionSignature.thrownTypedError,
self.errorTypes.contains(thrown.nominalTypeDecl.identity)
Expand Down Expand Up @@ -1043,6 +1067,14 @@ extension JNISwift2JavaGenerator {
).contains(.set)
}

/// The configured `nullableAnnotation` or `nonNullAnnotation` for a reference type.
func nullabilityAnnotation(_ javaType: JavaType, nullable: Bool) -> JavaAnnotation? {
if javaType.isPrimitive {
return nil
}
return (nullable ? config.nullableAnnotation : config.nonNullAnnotation).map { JavaAnnotation(className: $0) }
}

static func isOptional(_ type: SwiftType) -> Bool {
if case .nominal(let nominal) = type, nominal.nominalTypeDecl.knownTypeKind == .optional {
return true
Expand Down
12 changes: 12 additions & 0 deletions Sources/SwiftJavaConfigurationShared/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,18 @@ public struct Configuration: Codable {
optionalsAsNullable ?? false
}

/// Fully qualified name of a Java annotation marking parameters and results that can be `null`, such as
/// `androidx.annotation.Nullable` or `org.jetbrains.annotations.Nullable`, so Kotlin sees nullable types
/// instead of platform types. Applied to optionals with `optionalsAsNullable`. It must be applicable to
/// methods and parameters: type-use only annotations, such as JSpecify's, aren't supported. Only supported
/// in `jni` mode.
public var nullableAnnotation: String?

/// Fully qualified name of a Java annotation marking parameters and results that are never `null`, such as
/// `androidx.annotation.NonNull` or `org.jetbrains.annotations.NotNull`, so Kotlin sees non-null types instead
/// of platform types. Applied to the other reference types. Same requirements as `nullableAnnotation`.
public var nonNullAnnotation: String?

/// 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 @@ -278,6 +278,28 @@ Only supported in `jni` mode.

---

#### nullableAnnotation

- **Type:** `String?`

Fully qualified name of a Java annotation marking parameters and results that can be `null`, such as
`androidx.annotation.Nullable` or `org.jetbrains.annotations.Nullable`, so Kotlin sees nullable types
instead of platform types. Applied to optionals with `optionalsAsNullable`. It must be applicable to
methods and parameters: type-use only annotations, such as JSpecify's, aren't supported. Only supported
in `jni` mode.

---

#### nonNullAnnotation

- **Type:** `String?`

Fully qualified name of a Java annotation marking parameters and results that are never `null`, such as
`androidx.annotation.NonNull` or `org.jetbrains.annotations.NotNull`, so Kotlin sees non-null types instead
of platform types. Applied to the other reference types. Same requirements as `nullableAnnotation`.

---

#### generatedJavaSourcesListFileOutput

- **Type:** `String?`
Expand Down
104 changes: 104 additions & 0 deletions Tests/JExtractSwiftTests/JNI/JNINullabilityAnnotationsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//===----------------------------------------------------------------------===//
//
// 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

/// `nullableAnnotation` and `nonNullAnnotation` give Kotlin nullable and non-null types instead of platform types.
@Suite
struct JNINullabilityAnnotationsTests {
func config(optionalsAsNullable: Bool) -> Configuration {
var config = Configuration()
config.optionalsAsNullable = optionalsAsNullable
config.nullableAnnotation = "androidx.annotation.Nullable"
config.nonNullAnnotation = "androidx.annotation.NonNull"
return config
}

let source =
"""
public struct Item {
public var note: String?
}
public func find(name: String, limit: Int64, tag: String?) -> Item? {}
public func load() async -> String {}
public func invert(_ flag: Bool?) -> Bool? {}
"""

@Test
func parametersAndResults() throws {
try assertOutput(
input: source,
config: config(optionalsAsNullable: true),
.jni,
.java,
detectChunkByInitialLines: 2,
expectedChunks: [
"import androidx.annotation.Nullable;",
"import androidx.annotation.NonNull;",
// Primitives aren't annotated.
"""
@Nullable
public static Item find(@NonNull java.lang.String name, long limit, @Nullable String tag, SwiftArena swiftArena) {
""",
// An async result is a future, never `null`.
"""
@NonNull
public static java.util.concurrent.CompletableFuture<java.lang.String> load() {
""",
"""
@Nullable
public String getNote() {
""",
"public void setNote(@Nullable String newValue) {",
// Packed with its discriminator into one native result.
"""
@Nullable
public static Boolean invert(@Nullable Boolean flag) {
""",
]
)
}

@Test
func javaOptionalsAreNonNull() throws {
try assertOutput(
input: source,
config: config(optionalsAsNullable: false),
.jni,
.java,
detectChunkByInitialLines: 2,
expectedChunks: [
"""
@NonNull
public static java.util.Optional<Item> find(@NonNull java.lang.String name, long limit, @NonNull Optional<String> tag, SwiftArena swiftArena) {
"""
]
)
}

@Test
func noAnnotationsByDefault() throws {
try assertOutput(
input: source,
.jni,
.java,
detectChunkByInitialLines: 1,
expectedChunks: [
"public static java.util.Optional<Item> find(java.lang.String name, long limit, Optional<String> tag, SwiftArena swiftArena) {"
],
notExpectedChunks: ["@NonNull", "@Nullable"]
)
}
}
Loading