diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ExtensionMessageProvider.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ExtensionMessageProvider.swift new file mode 100644 index 000000000..dbb907efe --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ExtensionMessageProvider.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public protocol ExtensionMessageProvider { + func message() -> String +} + +extension ExtensionMessageProvider { + public func decoratedMessage() -> String { "[\(message())]" } + + public var messageLength: Int64 { Int64(message().utf8.count) } + + public static var prefix: String { "prefix" } + + public static func suffix() -> String { "suffix" } +} + +struct DefaultExtensionMessageProvider: ExtensionMessageProvider { + func message() -> String { "Swift" } +} + +public func formatExtensionMessage(_ provider: some ExtensionMessageProvider) -> String { + "\(provider.decoratedMessage())|\(provider.messageLength)|\(type(of: provider).prefix)|\(type(of: provider).suffix())" +} + +public func makeExtensionMessageProvider() -> any ExtensionMessageProvider { + DefaultExtensionMessageProvider() +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ProtocolExtensionTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ProtocolExtensionTest.java new file mode 100644 index 000000000..07d1b3f94 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ProtocolExtensionTest.java @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// 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.swift; + +import org.junit.jupiter.api.Test; +import org.swift.swiftkit.core.SwiftArena; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class ProtocolExtensionTest { + @Test + void extensionHelpersUseJavaRequirements() { + ExtensionMessageProvider provider = () -> "hello"; + assertEquals("[hello]|5|prefix|suffix", MySwiftLibrary.formatExtensionMessage(provider)); + } + + @Test + void extensionHelpersReadTheProvidedJavaValue() { + ExtensionMessageProvider provider = () -> "custom"; + assertEquals("[custom]|6|prefix|suffix", MySwiftLibrary.formatExtensionMessage(provider)); + } + + @Test + void returnedBoxUsesTheSwiftImplementation() { + try (var arena = SwiftArena.ofConfined()) { + ExtensionMessageProvider provider = MySwiftLibrary.makeExtensionMessageProvider(arena); + assertEquals("Swift", provider.message()); + assertEquals("[Swift]|5|prefix|suffix", MySwiftLibrary.formatExtensionMessage(provider)); + } + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index 236977e5e..eb8120a2c 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -105,23 +105,26 @@ extension JNISwift2JavaGenerator { struct JavaInterfaceProtocolWrapperGenerator { func generate(for type: ExtractedNominalType) throws -> JavaInterfaceSwiftWrapper { - if !type.initializers.isEmpty - || type.methods.contains(where: \.isStatic) - || type.variables.contains(where: \.isStatic) + let methods = type.methods.filter(\.isProtocolRequirement) + let variableAccessors = type.variables.filter(\.isProtocolRequirement) + + if type.initializers.contains(where: \.isProtocolRequirement) + || methods.contains(where: \.isStatic) + || variableAccessors.contains(where: \.isStatic) { throw JavaTranslationError.protocolStaticRequirementsNotSupported } - let functions = try type.methods.map { method in + let functions = try methods.map { method in try translate(function: method) } // FIXME: Finish support for variables - if !type.variables.isEmpty { + if !variableAccessors.isEmpty { throw JavaTranslationError.protocolVariablesNotSupported } - let variables = try Dictionary(grouping: type.variables, by: { $0.swiftDecl.id }).map { (id, funcs) in + let variables = try Dictionary(grouping: variableAccessors, by: { $0.swiftDecl.id }).map { (id, funcs) in precondition(funcs.count > 0 && funcs.count <= 2, "Variables must contain a getter and optionally a setter") guard let getter = funcs.first(where: { $0.apiKind == .getter }) else { fatalError("Getter not found for variable with imported funcs: \(funcs)") diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index dbb6e23ab..f775a293c 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -204,7 +204,7 @@ extension JNISwift2JavaGenerator { /// 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 + requirement.isProtocolRequirement && !requirement.isStatic && !requirement.isClass && self.translatedDecl(for: requirement) != nil } } diff --git a/Sources/SwiftExtract/ExtractedDecls+ProtocolRequirements.swift b/Sources/SwiftExtract/ExtractedDecls+ProtocolRequirements.swift new file mode 100644 index 000000000..41016b8b7 --- /dev/null +++ b/Sources/SwiftExtract/ExtractedDecls+ProtocolRequirements.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntax + +extension ExtractedFunc { + /// Whether this declaration is a requirement in a protocol body, rather than + /// an implementation or an additional member declared in an extension. + public var isProtocolRequirement: Bool { + var parent = swiftDecl.parent + while let node = parent { + if node.is(ProtocolDeclSyntax.self) { return true } + if node.isProtocol(DeclGroupSyntax.self) { return false } + parent = node.parent + } + return false + } +} diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md index 24d50e508..51080667c 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/FeaturesJextract.md @@ -539,6 +539,12 @@ currently supports tuples of primitive elements only. Swift `protocol` types are imported as Java `interface`s. Concrete types wrapping a Swift instance can be passed to protocol-typed parameters. +In JNI mode, only requirements declared in the protocol body become Java callback +requirements. Additional methods, computed properties, static helpers, and +initializers declared in protocol extensions retain their Swift implementations; +they do not add requirements to the Java interface or prevent an otherwise +supported protocol from being implemented in Java. + > Note: `any DataProtocol` is handled as `Foundation.Data` in FFM mode; see below. @TabNavigator { diff --git a/Tests/JExtractSwiftTests/JNI/JNIProtocolExtensionTests.swift b/Tests/JExtractSwiftTests/JNI/JNIProtocolExtensionTests.swift new file mode 100644 index 000000000..5c5f70545 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIProtocolExtensionTests.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +@Suite +struct JNIProtocolExtensionTests { + private var config: Configuration { + var config = Configuration() + config.enableJavaCallbacks = true + return config + } + + private let declaration = """ + public protocol Callback { + func message() -> String + } + """ + + @Test("Extension methods are not Java callback requirements", arguments: [false, true]) + func extensionMethod_java(callbacks: Bool) throws { + var config = config + config.enableJavaCallbacks = callbacks + try assertOutput( + input: declaration + """ + \nextension Callback { + public func decoratedMessage() -> String { "[\\(message())]" } + } + """, + config: config, + .jni, + .java, + expectedChunks: ["public java.lang.String message();"], + notExpectedChunks: ["public java.lang.String decoratedMessage();"] + ) + } + + @Test( + "Extension helpers do not disable Java callbacks", + arguments: [ + "public var length: Int64 { 0 }", + "public static func prefix() -> String { \"[\" }", + "public static var suffix: String { \"]\" }", + "public init(value: Int64) { fatalError() }", + ] + ) + func extensionHelpers_java(member: String) throws { + try assertOutput( + input: declaration + "\nextension Callback { \(member) }", + config: config, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public interface Callback extends SwiftDowncastable { + ... + public java.lang.String message(); + """ + ], + notExpectedChunks: ["public long getLength();"] + ) + } + + @Test("Swift callback wrappers inherit extension implementations") + func extensionHelpers_swift() throws { + try assertOutput( + input: declaration + """ + \nextension Callback { + public func decoratedMessage() -> String { "[\\(message())]" } + public var length: Int64 { 0 } + } + public func call(_ callback: some Callback) -> String + """, + config: config, + .jni, + .swift, + expectedChunks: [ + "protocol SwiftJavaCallbackWrapper: Callback", + "func message() -> String", + ], + notExpectedChunks: [ + "public func decoratedMessage() -> String", + "public var length: Int64", + ] + ) + } + + @Test("Returned protocol boxes only dispatch actual requirements") + func existentialBox_java() throws { + try assertOutput( + input: declaration + """ + \nextension Callback { + public func decoratedMessage() -> String { "[\\(message())]" } + public var length: Int64 { 0 } + } + public func makeCallback() -> any Callback + """, + config: config, + .jni, + .java, + expectedChunks: [ + "final class CallbackBox implements JNISwiftInstance, Callback", + "public java.lang.String message()", + ], + notExpectedChunks: [ + "public java.lang.String decoratedMessage()", + "public long getLength()", + ] + ) + } + + @Test( + "Unsupported requirements still prevent Java implementations", + arguments: [ + "var count: Int64 { get }", + "static func make() -> Callback", + "static var count: Int64 { get }", + "init()", + ] + ) + func unsupportedRequirement_java(requirement: String) throws { + try assertOutput( + input: "public protocol Callback { func message() -> String; \(requirement) }", + config: config, + .jni, + .java, + expectedChunks: ["public interface Callback extends JNISwiftInstance, SwiftDowncastable"] + ) + } +} diff --git a/Tests/SwiftExtractTests/ProtocolRequirementTests.swift b/Tests/SwiftExtractTests/ProtocolRequirementTests.swift new file mode 100644 index 000000000..ce4e6757d --- /dev/null +++ b/Tests/SwiftExtractTests/ProtocolRequirementTests.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftExtract +import Testing + +@Suite +struct ProtocolRequirementTests { + @Test + func distinguishesRequirementsFromExtensionMembers() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Callback { + init() + func message() -> String + var count: Int64 { get set } + subscript(index: Int64) -> Int64 { get } + static func make() -> Callback + } + extension Callback { + public init(value: Int64) { self.init() } + public func message() -> String { "default" } + public func helper() {} + public var length: Int64 { 0 } + } + public struct Plain { + public init() {} + public func message() -> String { "plain" } + public var count: Int64 { 0 } + } + public func message() -> String { "global" } + """ + ) + ], + moduleName: "Test" + ) + let callback = try #require(result.extractedTypes["Callback"]) + let messages = callback.methods.filter { $0.name == "message" } + #expect(messages.count == 2) + #expect(messages.filter(\.isProtocolRequirement).count == 1) + #expect(callback.methods.first { $0.name == "helper" }?.isProtocolRequirement == false) + #expect(callback.methods.first { $0.name == "make" }?.isProtocolRequirement == true) + let count = callback.variables.filter { $0.name == "count" } + #expect(count.count == 2) + #expect(count.allSatisfy { $0.isProtocolRequirement }) + #expect(callback.variables.first { $0.name == "length" }?.isProtocolRequirement == false) + #expect(callback.initializers.count == 2) + #expect(callback.initializers.filter(\.isProtocolRequirement).count == 1) + let subscripts = (callback.methods + callback.variables).filter { $0.apiKind == .subscriptGetter } + #expect(subscripts.count == 1) + #expect(subscripts.allSatisfy { $0.isProtocolRequirement }) + let plain = try #require(result.extractedTypes["Plain"]) + #expect((plain.methods + plain.variables + plain.initializers).allSatisfy { !$0.isProtocolRequirement }) + #expect(result.extractedGlobalFuncs.allSatisfy { !$0.isProtocolRequirement }) + } +}