Skip to content
Open
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
Expand Up @@ -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)")
}
Expand Down Expand Up @@ -104,15 +107,15 @@ 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)
{
throw JavaTranslationError.protocolStaticRequirementsNotSupported
}

let functions = try type.methods.map { method in
let functions = try methods.map { method in
try translate(function: method)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 43 additions & 3 deletions Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,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
Expand All @@ -182,6 +218,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<ObjectIdentifier> = []
var queue: [ExtractedNominalType] = [type]
Expand All @@ -191,6 +231,6 @@ extension JNISwift2JavaGenerator {
methods.append(contentsOf: self.supportedProtocolRequirements(of: current))
queue.append(contentsOf: inheritedProtocols(of: current))
}
return methods
return uniqueProtocolRequirements(methods)
}
}
12 changes: 12 additions & 0 deletions Tests/JExtractSwiftTests/Asserts/TextAssertions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -112,6 +113,7 @@ func assertOutput(
dump: dump,
expectedChunks: expectedChunks,
notExpectedChunks: notExpectedChunks,
expectedOccurrences: expectedOccurrences,
detectChunkByInitialLines: _detectChunkByInitialLines,
fileID: fileID,
filePath: filePath,
Expand All @@ -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,
Expand All @@ -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
}
Expand Down
191 changes: 191 additions & 0 deletions Tests/JExtractSwiftTests/JNI/JNIProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<JNIEnv?>!, 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(
Expand Down
Loading