From 12a26cd67e8e3052553ff54db856a822febb941b Mon Sep 17 00:00:00 2001 From: Himeshi Date: Fri, 23 Oct 2026 15:18:09 +0900 Subject: [PATCH] JNI: exclude protocol extension members from callback requirements Protocol extensions provide Swift implementations rather than additional Java callback requirements. Distinguish those members at extraction time and filter them when generating Java interfaces, existential boxes, and Swift callback wrappers. Only unsupported requirements declared in the protocol body should prevent a Java implementation. Add regression tests and Java runtime coverage for calling Swift extension helpers through Java callbacks and returned protocol boxes. --- .../ExtensionMessageProvider.swift | 39 +++++ .../example/swift/ProtocolExtensionTest.java | 43 ++++++ ...Generator+InterfaceWrapperGeneration.swift | 15 +- .../JNI/JNISwift2JavaGenerator.swift | 2 +- .../ExtractedDecls+ProtocolRequirements.swift | 29 ++++ .../Documentation.docc/FeaturesJextract.md | 6 + .../JNI/JNIProtocolExtensionTests.swift | 144 ++++++++++++++++++ .../ProtocolRequirementTests.swift | 70 +++++++++ 8 files changed, 341 insertions(+), 7 deletions(-) create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ExtensionMessageProvider.swift create mode 100644 Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/ProtocolExtensionTest.java create mode 100644 Sources/SwiftExtract/ExtractedDecls+ProtocolRequirements.swift create mode 100644 Tests/JExtractSwiftTests/JNI/JNIProtocolExtensionTests.swift create mode 100644 Tests/SwiftExtractTests/ProtocolRequirementTests.swift 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 }) + } +}