Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
2 changes: 1 addition & 1 deletion Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
29 changes: 29 additions & 0 deletions Sources/SwiftExtract/ExtractedDecls+ProtocolRequirements.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
144 changes: 144 additions & 0 deletions Tests/JExtractSwiftTests/JNI/JNIProtocolExtensionTests.swift
Original file line number Diff line number Diff line change
@@ -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"]
)
}
}
70 changes: 70 additions & 0 deletions Tests/SwiftExtractTests/ProtocolRequirementTests.swift
Original file line number Diff line number Diff line change
@@ -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 })
}
}
Loading