From 64f762a3dd2df3ad6ba7ce947a0f8261ca54a3d1 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 17:32:36 +0100 Subject: [PATCH 1/2] JNI: arrays of optionals, dictionaries and sets Arrays were only converted for primitive, String, jextracted-class and nested-array elements; `[Tag?]` and `[[String: Int64]]` were skipped. Such arrays now use the existing `ArrayBridge`, which converts element by element with the bridges dictionaries already use. The public Java method is typed (`Optional[]`, `SwiftDictionaryMap[]`); the native method passes `Object[]` and the wrapper casts with a new `.cast` conversion step. Optional primitive elements are `Optional`, the class `OptionalBridge` creates. The skipped-declarations test used these two shapes as its unsupported examples; it now uses arrays of tuples and of closures. Co-Authored-By: Claude Opus 5.5 --- .../Sources/MySwiftLibrary/Arrays.swift | 17 ++++ .../java/com/example/swift/ArraysTest.java | 36 ++++++++ ...ISwift2JavaGenerator+JavaTranslation.swift | 56 ++++++++++++- ...wift2JavaGenerator+NativeTranslation.swift | 38 +++++++++ .../JNI/JNIBridgedArrayElementsTests.swift | 84 +++++++++++++++++++ .../JNI/JNISkippedDeclarationsTests.swift | 10 +-- 6 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 Tests/JExtractSwiftTests/JNI/JNIBridgedArrayElementsTests.swift diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Arrays.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Arrays.swift index 526faf8e2..70d5835a9 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Arrays.swift +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Arrays.swift @@ -73,3 +73,20 @@ public func nestedStringArray(array: [[String]]) -> [[String]] { public func nestedLongArray(array: [[Int64]]) -> [[Int64]] { array } + +// Elements converted one by one: optionals, dictionaries and sets +public func optionalLongArray(array: [Int64?]) -> [Int64?] { + array +} + +public func optionalObjectArray(array: [MySwiftClass?]) -> [MySwiftClass?] { + array +} + +public func dictionaryArray() -> [[String: Int64]] { + [["a": 1], ["b": 2, "c": 3]] +} + +public func setArray() -> [Set] { + [["a", "b"], ["c"]] +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ArraysTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ArraysTest.java index 9151ead56..8625a6408 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ArraysTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ArraysTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test; import org.swift.swiftkit.core.SwiftArena; +import java.util.Optional; import java.util.OptionalLong; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; @@ -184,4 +185,39 @@ void nestedStringArray_emptyInner() { assertArrayEquals(new String[] { "a" }, result[1]); assertArrayEquals(new String[] {}, result[2]); } + + @Test + void optionalLongArray() { + @SuppressWarnings("unchecked") + Optional[] input = new Optional[] { Optional.of(1L), Optional.empty(), Optional.of(3L) }; + assertArrayEquals(input, MySwiftLibrary.optionalLongArray(input)); + } + + @Test + void optionalObjectArray() { + try (var arena = SwiftArena.ofConfined()) { + @SuppressWarnings("unchecked") + Optional[] input = new Optional[] { Optional.of(MySwiftClass.init(7, 8, arena)), Optional.empty() }; + Optional[] result = MySwiftLibrary.optionalObjectArray(input); + assertEquals(2, result.length); + assertEquals(7, result[0].orElseThrow().getX()); + assertTrue(result[1].isEmpty()); + } + } + + @Test + void dictionaryArray() { + var result = MySwiftLibrary.dictionaryArray(); + assertEquals(2, result.length); + assertEquals(1L, result[0].get("a")); + assertEquals(3L, result[1].get("c")); + } + + @Test + void setArray() { + var result = MySwiftLibrary.setArray(); + assertEquals(2, result.length); + assertTrue(result[0].contains("b")); + assertEquals(1, result[1].size()); + } } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index e607a4483..4ba08fa11 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -1568,6 +1568,17 @@ extension JNISwift2JavaGenerator { conversion: .requireNonNull(.placeholder, message: "\(parameterName) must not be null") ) + case .nominal(let nominalType) where nominalType.isBridgedArrayElement: + let javaType = try bridgedArrayElementJavaType( + elementType, + genericParameters: genericParameters, + genericRequirements: genericRequirements + ) + return TranslatedParameter( + parameter: JavaParameter(name: parameterName, type: .array(javaType), annotations: parameterAnnotations), + conversion: .requireNonNull(.placeholder, message: "\(parameterName) must not be null") + ) + case .nominal(let nominalType): if let knownType = nominalType.nominalTypeDecl.knownTypeKind { guard let javaType = JNIJavaTypeTranslator.translate(knownType: knownType, config: self.config) else { @@ -1642,6 +1653,21 @@ extension JNISwift2JavaGenerator { conversion: .placeholder ) + case .nominal(let nominalType) where nominalType.isBridgedArrayElement: + let javaType = try bridgedArrayElementJavaType( + elementType, + genericParameters: genericParameters, + genericRequirements: genericRequirements + ) + // The native method returns the array `ArrayBridge` created, whose static type is `Object[]`. + return TranslatedResult( + javaType: .array(javaType), + nativeJavaType: .array(.javaLangObject), + annotations: annotations, + outParameters: [], + conversion: .cast(.placeholder, .array(javaType)) + ) + case .nominal(let nominalType): let nominalTypeName = nominalType.nominalTypeDecl.qualifiedName if let knownType = nominalType.nominalTypeDecl.knownTypeKind { @@ -1704,6 +1730,28 @@ extension JNISwift2JavaGenerator { } } + /// The Java type of an element converted by `ArrayBridge`. Its `OptionalBridge` always creates a + /// `java.util.Optional`, also for primitives (`Optional`, not `OptionalLong`). + func bridgedArrayElementJavaType( + _ swiftType: SwiftType, + genericParameters: [SwiftGenericParameterDeclaration], + genericRequirements: [SwiftGenericRequirement], + ) throws -> JavaType { + if case .nominal(let nominalType) = swiftType, case .optional(let wrapped) = nominalType.asKnownType { + let wrappedJavaType = try bridgedArrayElementJavaType( + wrapped, + genericParameters: genericParameters, + genericRequirements: genericRequirements + ) + return .optional(wrappedJavaType.boxedType) + } + return try translateGenericTypeParameter( + swiftType, + genericParameters: genericParameters, + genericRequirements: genericRequirements, + ) + } + func javaTypeForDictionaryComponent( _ swiftType: SwiftType, genericParameters: [SwiftGenericParameterDeclaration], @@ -2076,6 +2124,9 @@ extension JNISwift2JavaGenerator { indirect case requireNonNull(JavaNativeConversionStep, message: String) + /// `(type) inner` + indirect case cast(JavaNativeConversionStep, JavaType) + /// Constructs a TupleN from out-parameter arrays. /// E.g. `new Tuple2<>(result_0$[0], result_1$[0])` case tupleFromOutParams(tupleClassName: String, elements: [(outParamName: String, elementConversion: JavaNativeConversionStep)]) @@ -2242,6 +2293,9 @@ extension JNISwift2JavaGenerator { let inner = inner.render(&printer, placeholder) return #"Objects.requireNonNull(\#(inner), "\#(message)")"# + case .cast(let inner, let type): + return "(\(type)) \(inner.render(&printer, placeholder))" + case .tupleFromOutParams(let tupleClassName, let elements): var args: [String] = [] for element in elements { @@ -2315,7 +2369,7 @@ extension JNISwift2JavaGenerator { case .print(let inner): return inner.requiresSwiftArena - case .requireNonNull(let inner, _): + case .requireNonNull(let inner, _), .cast(let inner, _): return inner.requiresSwiftArena case .tupleFromOutParams(_, let elements): diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 47bda010a..3f15cd709 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -1025,6 +1025,19 @@ extension JNISwift2JavaGenerator { outParameters: [] ) + case .nominal(let nominalType) where nominalType.isBridgedArrayElement: + // Optionals, dictionaries and sets are converted element by element with their bridges. + let arrayBridge = try bridgeTypeName(for: knownTypes.arraySugar(elementType)) + return NativeResult( + javaType: .array(.javaLangObject), + conversion: .method( + .constant(arrayBridge), + function: "toJavaObject", + arguments: [(nil, .placeholder), ("in", .constant("environment"))] + ), + outParameters: [] + ) + case .nominal(let nominalType): let nominalTypeName = nominalType.nominalTypeDecl.qualifiedName if let knownType = nominalType.nominalTypeDecl.knownTypeKind { @@ -1107,6 +1120,21 @@ extension JNISwift2JavaGenerator { conversionCheck: nil ) + case .nominal(let nominalType) where nominalType.isBridgedArrayElement: + let arrayBridge = try bridgeTypeName(for: knownTypes.arraySugar(elementType)) + return NativeParameter( + parameters: [ + JavaParameter(name: parameterName, type: .array(.javaLangObject)) + ], + conversion: .method( + .constant(arrayBridge), + function: "fromJavaObject", + arguments: [(nil, .placeholder), ("in", .constant("environment"))] + ), + indirectConversion: nil, + conversionCheck: nil + ) + case .nominal(let nominalType): if let knownType = nominalType.nominalTypeDecl.knownTypeKind { guard let javaType = JNIJavaTypeTranslator.translate(knownType: knownType, config: self.config), @@ -2230,3 +2258,13 @@ extension JNISwift2JavaGenerator { } } } + +extension SwiftNominalType { + /// Array elements converted with `ArrayBridge` rather than to a primitive or pointer array. + var isBridgedArrayElement: Bool { + switch nominalTypeDecl.knownTypeKind { + case .optional, .dictionary, .set: true + default: false + } + } +} diff --git a/Tests/JExtractSwiftTests/JNI/JNIBridgedArrayElementsTests.swift b/Tests/JExtractSwiftTests/JNI/JNIBridgedArrayElementsTests.swift new file mode 100644 index 000000000..0d161c51f --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIBridgedArrayElementsTests.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing + +/// Arrays of optionals, dictionaries and sets are converted element by element with `ArrayBridge`. +@Suite +struct JNIBridgedArrayElementsTests { + @Test + func optionalElements_java() throws { + try assertOutput( + input: "public func f(array: [Int64?]) -> [Int64?] {}", + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public static java.util.Optional[] f(java.util.Optional[] array) { + return (java.util.Optional[]) SwiftModule.$f(Objects.requireNonNull(array, "array must not be null")); + } + """, + "private static native java.lang.Object[] $f(java.lang.Object[] array);", + ] + ) + } + + @Test + func optionalElements_swift() throws { + try assertOutput( + input: "public func f(array: [Int64?]) -> [Int64?] {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + "return ArrayBridge>>.toJavaObject(SwiftModule.f(array: ArrayBridge>>.fromJavaObject(array, in: environment)), in: environment)" + ] + ) + } + + @Test + func dictionaryElements_java() throws { + try assertOutput( + input: "public func f() -> [[String: Int64]] {}", + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public static org.swift.swiftkit.core.collections.SwiftDictionaryMap[] f() { + return (org.swift.swiftkit.core.collections.SwiftDictionaryMap[]) SwiftModule.$f(); + } + """ + ] + ) + } + + @Test + func jextractedOptionalElements_java() throws { + try assertOutput( + input: """ + public struct Tag {} + public func f() -> [Tag?] {} + """, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static java.util.Optional[] f() {" + ] + ) + } +} diff --git a/Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift b/Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift index 2ca570200..203942e09 100644 --- a/Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift @@ -27,8 +27,8 @@ struct JNISkippedDeclarationsTests { public var name: String } public func supported() -> Tag { fatalError() } - public func sparse() -> [Tag?] { [] } - public func nested() -> [[String: Int64]] { [] } + public func pairs() -> [(Int64, String)] { [] } + public func callbacks() -> [() -> Void] { [] } """ private func generate(_ source: String, sink: SkippedDeclarationCollector? = nil) throws -> JNISwift2JavaGenerator { @@ -56,9 +56,9 @@ struct JNISkippedDeclarationsTests { func translationFailuresAreRecordedOnce() throws { let generator = try generate(source) let skipped = generator.skippedDeclarations.sorted() - #expect(skipped.count == 2, "\(skipped)") - #expect(skipped[0].hasPrefix("nested()")) - #expect(skipped[1].hasPrefix("sparse()")) + try #require(skipped.count == 2, "\(skipped)") + #expect(skipped[0].hasPrefix("callbacks()")) + #expect(skipped[1].hasPrefix("pairs()")) } @Test From b96b02a07a030d26cfb27ac47049cc9083136f77 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 17:32:36 +0100 Subject: [PATCH 2/2] Samples: arrays of optionals and of dictionaries from Kotlin Co-Authored-By: Claude Opus 5.5 --- .../KotlinSampleLibrary/Collections.swift | 30 ++++++++++++++ .../com/example/kotlin/CollectionsTest.kt | 40 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Collections.swift create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/CollectionsTest.kt diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Collections.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Collections.swift new file mode 100644 index 000000000..fae12f900 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Collections.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Arrays whose elements are optionals or dictionaries. +public enum Watchlist { + /// A row per symbol; a symbol without a quote yet is `nil`. + public static func quotes() -> [Item?] { + [Item(id: "a", count: 1), nil, Item(id: "c", count: 3)] + } + + /// Positions per page. + public static func pages() -> [[String: Int64]] { + [["a": 1], ["b": 2, "c": 3]] + } + + public static func total(counts: [Int64?]) -> Int64 { + counts.compactMap { $0 }.reduce(0, +) + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/CollectionsTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/CollectionsTest.kt new file mode 100644 index 000000000..a9676aff2 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/CollectionsTest.kt @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// 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 java.util.Optional +import kotlin.jvm.optionals.getOrNull +import kotlin.test.Test +import kotlin.test.assertEquals + +class CollectionsTest { + @Test + fun arrayOfOptionals() { + val ids = Watchlist.quotes().map { it.getOrNull()?.id } + assertEquals(listOf("a", null, "c"), ids) + } + + @Test + fun arrayOfDictionaries() { + val pages = Watchlist.pages() + assertEquals(listOf(1, 2), pages.map { it.size }) + assertEquals(3L, pages[1]["c"]) + } + + @Test + fun passAnArrayOfOptionals() { + assertEquals(4L, Watchlist.total(arrayOf(Optional.of(1L), Optional.empty(), Optional.of(3L)))) + } +}