diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config index d1e4b048f..adb4ff47c 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config @@ -4,5 +4,7 @@ "memoryManagementMode": "allowGlobalAutomatic", "failOnSkippedDeclarations": true, "payloadlessEnumsAsJavaEnums": true, - "optionalsAsNullable": true + "optionalsAsNullable": true, + "nullableAnnotation": "org.jetbrains.annotations.Nullable", + "nonNullAnnotation": "org.jetbrains.annotations.NotNull" } diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/NullablesTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/NullablesTest.kt index 9fc6caffe..f3ad7ddb3 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/NullablesTest.kt +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/NullablesTest.kt @@ -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) + } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index e41a17ca1..94597ac57 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -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("") } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 2ee42c06f..97b426688 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -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) @@ -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 diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index b74e695c3..f711b6e6d 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -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? diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md index e8b6f5e5c..c3a415045 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -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?` diff --git a/Tests/JExtractSwiftTests/JNI/JNINullabilityAnnotationsTests.swift b/Tests/JExtractSwiftTests/JNI/JNINullabilityAnnotationsTests.swift new file mode 100644 index 000000000..d2ebcee22 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNINullabilityAnnotationsTests.swift @@ -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 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 find(@NonNull java.lang.String name, long limit, @NonNull Optional tag, SwiftArena swiftArena) { + """ + ] + ) + } + + @Test + func noAnnotationsByDefault() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static java.util.Optional find(java.lang.String name, long limit, Optional tag, SwiftArena swiftArena) {" + ], + notExpectedChunks: ["@NonNull", "@Nullable"] + ) + } +}