diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index 236977e5e..d8613a74d 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -35,7 +35,10 @@ extension JNISwift2JavaGenerator { do { let translator = JavaInterfaceProtocolWrapperGenerator() - wrappers[type] = try translator.generate(for: type) + wrappers[type] = try translator.generate( + for: type, + methods: uniqueProtocolRequirements(type.methods) + ) } catch { self.logger.warning("Failed to generate protocol wrapper for: '\(type.swiftNominal.qualifiedName)'; \(error)") } @@ -104,7 +107,7 @@ extension JNISwift2JavaGenerator { } struct JavaInterfaceProtocolWrapperGenerator { - func generate(for type: ExtractedNominalType) throws -> JavaInterfaceSwiftWrapper { + func generate(for type: ExtractedNominalType, methods: [ExtractedFunc]) throws -> JavaInterfaceSwiftWrapper { if !type.initializers.isEmpty || type.methods.contains(where: \.isStatic) || type.variables.contains(where: \.isStatic) @@ -112,7 +115,7 @@ extension JNISwift2JavaGenerator { throw JavaTranslationError.protocolStaticRequirementsNotSupported } - let functions = try type.methods.map { method in + let functions = try methods.map { method in try translate(function: method) } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 504514f57..59ea49a4f 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -173,7 +173,11 @@ extension JNISwift2JavaGenerator { let filename = "\(decl.effectiveJavaSimpleName).java" logger.debug("Printing contents: \(filename)") - resetForNewOutputFile(decl.initializers + decl.variables + decl.methods) + let methods = + decl.swiftNominal.kind == .protocol + ? self.supportedProtocolRequirements(of: decl) + : decl.initializers + decl.variables + decl.methods + resetForNewOutputFile(methods) printHeader(&printer) printPackage(&printer) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index c5ac7bba0..b32052b96 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -310,9 +310,11 @@ extension JNISwift2JavaGenerator { printHeader(&printer) printer.println() - self.currentJavaIdentifiers = JavaIdentifierFactory( - type.initializers + type.variables + type.methods - ) + let methods = + type.swiftNominal.kind == .protocol + ? self.allProtocolRequirementMethods(of: type) + : type.initializers + type.variables + type.methods + self.currentJavaIdentifiers = JavaIdentifierFactory(methods) switch type.swiftNominal.kind { case .actor, .class, .enum, .struct: diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index dbb6e23ab..b8577b9df 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -203,9 +203,45 @@ extension JNISwift2JavaGenerator { /// (getters/setters), excluding statics and anything whose signature /// doesn't translate (e.g. referencing `Self`/associated types). func supportedProtocolRequirements(of type: ExtractedNominalType) -> [ExtractedFunc] { - (type.methods + type.variables).filter { requirement in - !requirement.isStatic && !requirement.isClass && self.translatedDecl(for: requirement) != nil + uniqueProtocolRequirements( + (type.methods + type.variables).filter { requirement in + !requirement.isStatic && !requirement.isClass + && (try? self.javaTranslator.translate(requirement)) != nil + } + ) + } + + /// Compare the callable signature rather than source text. A default + /// implementation may use different access modifiers, local parameter names, + /// default arguments, or a narrower throwing effect from the protocol + /// requirement it implements. + func uniqueProtocolRequirements(_ methods: [ExtractedFunc]) -> [ExtractedFunc] { + var unique: [ExtractedFunc] = [] + for method in methods { + let duplicate = unique.contains { existing in + existing.apiKind == method.apiKind && existing.name == method.name + && normalizedProtocolSignature(existing) == normalizedProtocolSignature(method) + } + if !duplicate { + unique.append(method) + } } + return unique + } + + private func normalizedProtocolSignature(_ method: ExtractedFunc) -> SwiftFunctionSignature { + var signature = method.functionSignature + signature.selfParameter = nil + signature.effectSpecifiers.removeAll { $0 == .throws } + signature.thrownTypedError = nil + signature.parameters = signature.parameters.map { parameter in + var parameter = parameter + parameter.parameterName = nil + parameter.hasDefaultValue = false + parameter.defaultValueExpression = nil + return parameter + } + return signature } /// All wrappable requirements for `type` (a protocol), including those @@ -214,6 +250,10 @@ extension JNISwift2JavaGenerator { /// box's method bodies and per-requirement `@_cdecl` dispatch thunks, /// since the box must implement everything the protocol (directly or /// transitively) requires. + /// + /// A protocol requirement and its default implementation are extracted as + /// distinct ``ExtractedFunc`` instances. Keep the first occurrence so the + /// box emits one JNI thunk and one Java method for each callable signature. func allProtocolRequirementMethods(of type: ExtractedNominalType) -> [ExtractedFunc] { var visited: Set = [] var queue: [ExtractedNominalType] = [type] @@ -223,6 +263,6 @@ extension JNISwift2JavaGenerator { methods.append(contentsOf: self.supportedProtocolRequirements(of: current)) queue.append(contentsOf: inheritedProtocols(of: current)) } - return methods + return uniqueProtocolRequirements(methods) } } diff --git a/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift b/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift index 4471b1776..f5788067e 100644 --- a/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift +++ b/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift @@ -45,6 +45,7 @@ func assertOutput( moduleJavaPackages: [String: String] = [:], expectedChunks: [String], notExpectedChunks: [String] = [], + expectedOccurrences: [String: Int] = [:], fileID: String = #fileID, filePath: String = #filePath, line: Int = #line, @@ -112,6 +113,7 @@ func assertOutput( dump: dump, expectedChunks: expectedChunks, notExpectedChunks: notExpectedChunks, + expectedOccurrences: expectedOccurrences, detectChunkByInitialLines: _detectChunkByInitialLines, fileID: fileID, filePath: filePath, @@ -126,6 +128,7 @@ func assertOutput( dump: Bool = false, expectedChunks: [String], notExpectedChunks: [String] = [], + expectedOccurrences: [String: Int] = [:], detectChunkByInitialLines _detectChunkByInitialLines: Int = 4, fileID: String = #fileID, filePath: String = #filePath, @@ -149,6 +152,15 @@ func assertOutput( ) } + for (expectedChunk, expectedCount) in expectedOccurrences { + let actualCount = output.components(separatedBy: expectedChunk).count - 1 + #expect( + actualCount == expectedCount, + "Expected '\(expectedChunk)' to occur \(expectedCount) time(s), but found \(actualCount).", + sourceLocation: sourceLocation + ) + } + let gotLines = output.split(separator: "\n").filter { l in l.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).count > 0 } diff --git a/Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift b/Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift index fc1e971bf..e3452f91b 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift @@ -82,6 +82,51 @@ struct JNIProtocolTests { public func makeChild() -> any ChildProtocol """ + let protocolDefaultImplementationSource = """ + public protocol Test { + func action() + } + + public extension Test { + func action() {} + } + + public func makeTest() -> any Test + """ + + let protocolDefaultImplementationWithDifferentSpellingSource = """ + public protocol Test { + func action(_ value: Int64) + } + + public extension Test { + func action(_ otherValue: Int64) {} + } + + public func makeTest() -> any Test + """ + + let overloadedProtocolSource = """ + public protocol Overloaded { + func action(a: Int64) + func action(b: Int64) + } + + public func makeOverloaded() -> any Overloaded + """ + + let nonthrowingDefaultImplementationSource = """ + public protocol Test { + func work() throws + } + + public extension Test { + func work() {} + } + + public func makeTest() -> any Test + """ + @Test func generatesJavaInterface() throws { try assertOutput( @@ -542,6 +587,152 @@ struct JNIProtocolTests { ) } + @Test + func existentialBoxDispatchThunkWithDefaultImplementationIsUnique() throws { + var config = config + config.enableJavaCallbacks = false + + try assertOutput( + input: protocolDefaultImplementationSource, + config: config, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @_cdecl("Java_com_example_swift_TestBox__00024action__JJ") + public func Java_com_example_swift_TestBox__00024action__JJ(environment: UnsafeMutablePointer!, thisClass: jclass, selfPointer: jlong, selfTypePointer: jlong) { + ... + } + """ + ], + expectedOccurrences: [ + "@_cdecl(\"Java_com_example_swift_TestBox__00024action__JJ\")": 1, + "@_cdecl(\"Java_com_example_swift_TestBox__00024action": 1, + ] + ) + } + + @Test + func existentialBoxJavaMethodWithDefaultImplementationIsUnique() throws { + var config = config + config.enableJavaCallbacks = false + + try assertOutput( + input: protocolDefaultImplementationSource, + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public void action() { + TestBox.$action(this.$memoryAddress(), this.$typeMetadataAddress()); + } + private static native void $action(long selfPointer, long selfTypePointer); + """ + ], + notExpectedChunks: ["action_("], + expectedOccurrences: [ + "private static native void $action(long selfPointer, long selfTypePointer);": 1, + "public void action();": 1, + "public void action() {": 1, + ] + ) + } + + @Test + func defaultImplementationWithDifferentSpellingIsUnique() throws { + var config = config + config.enableJavaCallbacks = false + + try assertOutput( + input: protocolDefaultImplementationWithDifferentSpellingSource, + config: config, + .jni, + .swift, + expectedChunks: [], + expectedOccurrences: [ + "@_cdecl(\"Java_com_example_swift_TestBox__00024action__JJJ\")": 1, + "@_cdecl(\"Java_com_example_swift_TestBox__00024action": 1, + ] + ) + + try assertOutput( + input: protocolDefaultImplementationWithDifferentSpellingSource, + config: config, + .jni, + .java, + expectedChunks: [], + notExpectedChunks: ["action_("], + expectedOccurrences: [ + "public void action(long": 2, + "private static native void $action(long": 1, + ] + ) + } + + @Test + func defaultImplementationCallbackWrapperIsUnique() throws { + try assertOutput( + input: protocolDefaultImplementationSource, + config: config, + .jni, + .swift, + expectedChunks: [], + expectedOccurrences: [ + "func action() {": 1 + ] + ) + } + + @Test + func distinctProtocolOverloadsArePreserved() throws { + var config = config + config.enableJavaCallbacks = false + + try assertOutput( + input: overloadedProtocolSource, + config: config, + .jni, + .java, + expectedChunks: [], + expectedOccurrences: [ + "public void actionA(long": 2, + "public void actionB(long": 2, + ] + ) + } + + @Test + func nonthrowingDefaultImplementationIsUnique() throws { + var config = config + config.enableJavaCallbacks = false + + try assertOutput( + input: nonthrowingDefaultImplementationSource, + config: config, + .jni, + .swift, + expectedChunks: [], + expectedOccurrences: [ + "@_cdecl(\"Java_com_example_swift_TestBox__00024work": 1 + ] + ) + + try assertOutput( + input: nonthrowingDefaultImplementationSource, + config: config, + .jni, + .java, + expectedChunks: [], + expectedOccurrences: [ + "private static native void $work(": 1, + "public void work() throws Exception;": 1, + ] + ) + } + @Test func existentialBoxPropertyAccessors_swift() throws { try assertOutput(