diff --git a/Samples/SwiftJavaExtractFFMSampleApp/Sources/MySwiftLibrary/MySwiftLibrary.swift b/Samples/SwiftJavaExtractFFMSampleApp/Sources/MySwiftLibrary/MySwiftLibrary.swift index 95420d0ed..f67e0bfc5 100644 --- a/Samples/SwiftJavaExtractFFMSampleApp/Sources/MySwiftLibrary/MySwiftLibrary.swift +++ b/Samples/SwiftJavaExtractFFMSampleApp/Sources/MySwiftLibrary/MySwiftLibrary.swift @@ -66,6 +66,19 @@ public func getArray() -> [UInt8] { [1, 2, 3] } +// Tuple round-trips for jextract FFM (see `FFMTupleTest` in the sample app). +public func ffmTupleReturnPair() -> (Int32, Int64) { + (42, 43) +} + +public func ffmTupleSumPair(_ arg: (Int32, Int64)) -> Int64 { + Int64(arg.0) + arg.1 +} + +public func ffmTupleLabeledPair() -> (x: Int32, y: Int32) { + (x: 10, y: 20) +} + public func sumAllByteArrayElements(actuallyAnArray: UnsafeRawPointer, count: Int) -> Int { let bufferPointer = UnsafeRawBufferPointer(start: actuallyAnArray, count: count) let array = Array(bufferPointer) diff --git a/Samples/SwiftJavaExtractFFMSampleApp/src/test/java/com/example/swift/FFMTupleTest.java b/Samples/SwiftJavaExtractFFMSampleApp/src/test/java/com/example/swift/FFMTupleTest.java new file mode 100644 index 000000000..662dc846c --- /dev/null +++ b/Samples/SwiftJavaExtractFFMSampleApp/src/test/java/com/example/swift/FFMTupleTest.java @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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.swift; + +import org.junit.jupiter.api.Test; +import org.swift.swiftkit.core.tuple.Tuple2; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Runtime coverage for Swift tuples exported via jextract FFM (see {@code ffmTuple*} in {@link MySwiftLibrary}). + */ +public class FFMTupleTest { + + static { + System.loadLibrary(MySwiftLibrary.LIB_NAME); + } + + @Test + void ffmTupleReturnPair_roundTrip() { + Tuple2 result = MySwiftLibrary.ffmTupleReturnPair(); + assertEquals(42, result.$0); + assertEquals(43L, result.$1); + } + + @Test + void ffmTupleSumPair_acceptsTupleFromJava() { + long sum = MySwiftLibrary.ffmTupleSumPair(new Tuple2<>(5, 7L)); + assertEquals(12L, sum); + } + + @Test + void ffmTupleLabeledPair_preservesElementOrder() { + Tuple2 result = MySwiftLibrary.ffmTupleLabeledPair(); + assertEquals(10, result.$0); + assertEquals(20, result.$1); + } +} diff --git a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift index 2f2ceecc1..5eb47974b 100644 --- a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaBindingsPrinting.swift @@ -622,6 +622,12 @@ extension FFMSwift2JavaGenerator.JavaConversionStep { case .commaSeparated(let list, _): return list.contains(where: { $0.requiresSwiftArena }) + + case .replacingPlaceholder(let inner, _): + return inner.requiresSwiftArena + + case .tupleFromOutParams(_, let elements): + return elements.contains(where: { $0.elementConversion.requiresSwiftArena }) } } @@ -654,6 +660,12 @@ extension FFMSwift2JavaGenerator.JavaConversionStep { return inner.requiresTemporaryArena case .commaSeparated(let list, _): return list.contains(where: { $0.requiresTemporaryArena }) + + case .replacingPlaceholder(let inner, _): + return inner.requiresTemporaryArena + + case .tupleFromOutParams(_, let elements): + return elements.contains(where: { $0.elementConversion.requiresTemporaryArena }) } } @@ -754,6 +766,19 @@ extension FFMSwift2JavaGenerator.JavaConversionStep { case .readMemorySegment(let inner, let javaType): let inner = inner.render(&printer, placeholder) return "\(inner).get(\(ForeignValueLayout(javaType: javaType)!), 0)" + + case .replacingPlaceholder(let inner, let root): + return inner.render(&printer, root, placeholderForDowncall: placeholderForDowncall) + + case .tupleFromOutParams(let tupleClassName, let elements): + let args = elements.map { element in + element.elementConversion.render( + &printer, + element.outParamName, + placeholderForDowncall: placeholderForDowncall + ) + } + return "\(tupleClassName)(\(args.joined(separator: ", ")))" } } } diff --git a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaTranslation.swift index a88f50b66..221ef8891 100644 --- a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+JavaTranslation.swift @@ -191,7 +191,7 @@ extension FFMSwift2JavaGenerator { let translatedClosure = try translateFunctionType(name: paramName, swiftType: funcTy, cdeclType: cdeclTy) funcTypes.append(translatedClosure) case .tuple: - // TODO: Implement + // Tuple-typed closure parameters are not supported (same as JNI / lowering). break default: break @@ -462,9 +462,27 @@ extension FFMSwift2JavaGenerator { conversion: .swiftValueSelfSegment(.placeholder) ) - case .tuple: - // TODO: Implement. - throw JavaTranslationError.unhandledType(swiftType) + case .tuple([]): + return TranslatedParameter( + javaParameters: [ + JavaParameter( + name: parameterName, + type: .void, + annotations: parameterAnnotations + ) + ], + conversion: .placeholder + ) + + case .tuple(let elements): + return try translateTupleParameter( + elements: elements, + convention: convention, + parameterName: parameterName, + methodName: methodName, + genericParameters: genericParameters, + genericRequirements: genericRequirements + ) case .function: return TranslatedParameter( @@ -539,6 +557,56 @@ extension FFMSwift2JavaGenerator { } } + /// Tuple parameters: one `TupleN<…>` on the Java API; conversion reads `.$0`, `.$1`, … (mirrors JNI). + func translateTupleParameter( + elements: [SwiftTupleElement], + convention: SwiftParameterConvention, + parameterName: String, + methodName: String, + genericParameters: [SwiftGenericParameterDeclaration], + genericRequirements: [SwiftGenericRequirement] + ) throws -> TranslatedParameter { + let lowering = CdeclLowering(knownTypes: knownTypes) + var elementJavaTypes: [JavaType] = [] + var elementConversions: [JavaConversionStep] = [] + + for (idx, element) in elements.enumerated() { + let subLowered = try lowering.lowerParameter( + element.type, + convention: convention, + parameterName: "\(parameterName)_\(idx)", + genericParameters: genericParameters, + genericRequirements: genericRequirements + ) + let elementTranslated = try translateParameter( + type: element.type, + convention: convention, + parameterName: "\(parameterName)_\(idx)", + loweredParam: subLowered, + methodName: methodName, + genericParameters: genericParameters, + genericRequirements: genericRequirements + ) + guard elementTranslated.javaParameters.count == 1 else { + throw JavaTranslationError.unhandledType(element.type) + } + let extraction = JavaConversionStep.replacingPlaceholder( + elementTranslated.conversion, + placeholder: "\(parameterName).$\(idx)" + ) + elementConversions.append(extraction) + elementJavaTypes.append(elementTranslated.javaParameters[0].type.javaType) + } + + let javaType: JavaType = .tuple(elementTypes: elementJavaTypes) + return TranslatedParameter( + javaParameters: [ + JavaParameter(name: parameterName, type: javaType) + ], + conversion: .commaSeparated(elementConversions) + ) + } + /// Translate an Optional Swift API parameter to the user-facing Java API parameter. func translateOptionalParameter( wrappedType swiftType: SwiftType, @@ -722,9 +790,19 @@ extension FFMSwift2JavaGenerator { conversion: .wrapMemoryAddressUnsafe(.placeholder, javaType) ) - case .tuple: - // TODO: Implement. - throw JavaTranslationError.unhandledType(swiftType) + case .tuple([]): + return TranslatedResult( + javaResultType: .void, + annotations: resultAnnotations, + outParameters: [], + conversion: .placeholder + ) + + case .tuple(let elements): + return try translateTupleResult( + elements: elements, + resultAnnotations: resultAnnotations + ) case .array(let wrapped) where wrapped == knownTypes.uint8: return TranslatedResult( @@ -782,6 +860,59 @@ extension FFMSwift2JavaGenerator { } + /// Tuple results: indirect `MemorySegment` per element, then `new TupleN<…>(…)` (mirrors JNI out-arrays). + func translateTupleResult( + elements: [SwiftTupleElement], + resultAnnotations: [JavaAnnotation] + ) throws -> TranslatedResult { + var outParameters: [JavaParameter] = [] + var tupleElements: [(outParamName: String, elementConversion: JavaConversionStep)] = [] + var elementJavaTypes: [JavaType] = [] + + for (idx, element) in elements.enumerated() { + let (javaType, elementConversion) = try translateTupleElementResult(type: element.type) + outParameters.append(JavaParameter(name: "\(idx)", type: javaType)) + tupleElements.append((outParamName: "_result_\(idx)", elementConversion: elementConversion)) + elementJavaTypes.append(javaType) + } + + let javaResultType: JavaType = .tuple(elementTypes: elementJavaTypes) + let fullTupleClassName = javaResultType.fullyQualifiedClassName! + + return TranslatedResult( + javaResultType: javaResultType, + annotations: resultAnnotations, + outParameters: outParameters, + conversion: .tupleFromOutParams( + tupleClassName: "new \(fullTupleClassName)", + elements: tupleElements + ) + ) + } + + /// Single tuple element for the Java result (mirrors JNI `translateTupleElementResult`). + private func translateTupleElementResult(type: SwiftType) throws -> (JavaType, JavaConversionStep) { + switch type { + case .nominal(let nominalType): + if nominalType.nominalTypeDecl.knownTypeKind != nil { + if let cType = try? CType(cdeclType: type) { + return (cType.javaType, .readMemorySegment(.placeholder, as: cType.javaType)) + } + throw JavaTranslationError.unhandledType(type) + } + + guard !nominalType.isSwiftJavaWrapper else { + throw JavaTranslationError.unhandledType(type) + } + + let javaType: JavaType = .class(package: nil, name: nominalType.nominalTypeDecl.qualifiedName) + return (javaType, .wrapMemoryAddressUnsafe(.placeholder, javaType)) + + default: + throw JavaTranslationError.unhandledType(type) + } + } + func translate( swiftType: SwiftType ) throws -> JavaType { @@ -876,6 +1007,15 @@ extension FFMSwift2JavaGenerator { /// Refer an exploded argument suffixed with `_\(name)`. indirect case readMemorySegment(JavaConversionStep, as: JavaType) + /// Use `placeholder` as the root when rendering `inner` (same idea as JNI `replacingPlaceholder`). + indirect case replacingPlaceholder(JavaConversionStep, placeholder: String) + + /// Build `org.swift.swiftkit.core.tuple.TupleN` from indirect `MemorySegment` out params (JNI `tupleFromOutParams`). + case tupleFromOutParams( + tupleClassName: String, + elements: [(outParamName: String, elementConversion: JavaConversionStep)] + ) + var isPlaceholder: Bool { if case .placeholder = self { true } else { false } } diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md index a3645e630..d38f6323a 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SupportedFeatures.md @@ -65,7 +65,7 @@ SwiftJava's `swift-java jextract` tool automates generating Java bindings from S | Static functions or properties in generic type | ❌ | ❌ | | Generic parameters in functions: `func f(x: T)` | ❌ | ✅ | | Generic return values in functions: `func f() -> T` | ❌ | ❌ | -| Tuples: `(Int, String)`, `(A, B, C)` | ❌ | ❌ | +| Tuples: `(Int, String)`, `(A, B, C)` | ✅ | ✅ | | Protocols: `protocol` | ❌ | ✅ | | Protocols: `protocol` with associated types | ❌ | ❌ | | Protocols static requirements: `static func`, `init(rawValue:)` | ❌ | ❌ | diff --git a/Tests/JExtractSwiftTests/FFM/FFMTupleTests.swift b/Tests/JExtractSwiftTests/FFM/FFMTupleTests.swift new file mode 100644 index 000000000..b83b04adb --- /dev/null +++ b/Tests/JExtractSwiftTests/FFM/FFMTupleTests.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 + +@Suite +struct FFMTupleTests { + let source = """ + public func returnPair() -> (Int64, Int64) + public func takePair(_ arg: (Int64, Int64)) + public func labeledTuple() -> (x: Int32, y: Int32) + """ + + @Test + func returnPair_javaBindings() throws { + try assertOutput( + input: source, + .ffm, + .java, + expectedChunks: [ + """ + public static org.swift.swiftkit.core.tuple.Tuple2 returnPair() { + try(var arena$ = Arena.ofConfined()) { + MemorySegment _result_0 = arena$.allocate(SwiftValueLayout.SWIFT_INT64); + MemorySegment _result_1 = arena$.allocate(SwiftValueLayout.SWIFT_INT64); + swiftjava_SwiftModule_returnPair.call(_result_0, _result_1); + return new org.swift.swiftkit.core.tuple.Tuple2(_result_0.get(SwiftValueLayout.SWIFT_INT64, 0), _result_1.get(SwiftValueLayout.SWIFT_INT64, 0)); + } + } + """ + ] + ) + } + + @Test + func takePair_javaBindings() throws { + try assertOutput( + input: source, + .ffm, + .java, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + public static void takePair(org.swift.swiftkit.core.tuple.Tuple2 arg) { + swiftjava_SwiftModule_takePair__.call(arg.$0, arg.$1); + } + """ + ] + ) + } + + @Test + func labeledTuple_javaBindings() throws { + try assertOutput( + input: source, + .ffm, + .java, + expectedChunks: [ + """ + public static org.swift.swiftkit.core.tuple.Tuple2 labeledTuple() { + """, + """ + return new org.swift.swiftkit.core.tuple.Tuple2(_result_0.get(SwiftValueLayout.SWIFT_INT32, 0), _result_1.get(SwiftValueLayout.SWIFT_INT32, 0)); + """, + ] + ) + } + + @Test + func returnPair_swiftThunks() throws { + try assertOutput( + input: source, + .ffm, + .swift, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + @_cdecl("swiftjava_SwiftModule_returnPair") + public func swiftjava_SwiftModule_returnPair(_ _result_0: UnsafeMutablePointer, _ _result_1: UnsafeMutablePointer) { + let _result = returnPair() + _result_0.initialize(to: _result.0) + _result_1.initialize(to: _result.1) + } + """ + ] + ) + } + + @Test + func takePair_swiftThunks() throws { + try assertOutput( + input: source, + .ffm, + .swift, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + @_cdecl("swiftjava_SwiftModule_takePair__") + public func swiftjava_SwiftModule_takePair__(_ arg_0: Int64, _ arg_1: Int64) { + takePair((arg_0, arg_1)) + } + """ + ] + ) + } + + @Test + func labeledTuple_swiftThunks() throws { + try assertOutput( + input: source, + .ffm, + .swift, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + @_cdecl("swiftjava_SwiftModule_labeledTuple") + public func swiftjava_SwiftModule_labeledTuple(_ _result_0: UnsafeMutablePointer, _ _result_1: UnsafeMutablePointer) { + let _result = labeledTuple() + _result_0.initialize(to: _result.0) + _result_1.initialize(to: _result.1) + } + """ + ] + ) + } +}