From 9b54cdca50f282c59f5fca75f969c310f44be6c0 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 18:25:55 +0100 Subject: [PATCH 1/4] JNI: Java overloads for trailing default arguments Java and Kotlin callers had to pass every argument, including those with Swift defaults. Each function and initializer now gets an overload per trailing defaulted parameter it can be called without, like the existing variadic expansion. The overload's Swift thunk calls the function without those arguments, so Swift supplies its own defaults; no default expression is translated. An overload is skipped when a declared function already has that name and parameter types. JNI mode only; protocols are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../MySwiftLibrary/DefaultArguments.swift | 18 ++++ .../example/swift/DefaultArgumentsTest.java | 28 +++++ .../JNI/JNISwift2JavaGenerator.swift | 2 + Sources/SwiftExtract/AnalysisResult.swift | 19 ++++ Sources/SwiftExtract/ExtractedDecls.swift | 29 +++++ .../JNI/JNIDefaultArgumentsTests.swift | 102 ++++++++++++++++++ 6 files changed, 198 insertions(+) create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/DefaultArguments.swift create mode 100644 Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/DefaultArgumentsTest.java create mode 100644 Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/DefaultArguments.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/DefaultArguments.swift new file mode 100644 index 000000000..21b794fe8 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/DefaultArguments.swift @@ -0,0 +1,18 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Trailing default arguments are Java overloads; Swift fills in the values left out. +public func greeting(name: String = "world", excited: Bool = false) -> String { + "Hello, \(name)\(excited ? "!" : ".")" +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/DefaultArgumentsTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/DefaultArgumentsTest.java new file mode 100644 index 000000000..00c6c01a7 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/DefaultArgumentsTest.java @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// 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 static org.junit.jupiter.api.Assertions.*; + +public class DefaultArgumentsTest { + @Test + void overloadsUseSwiftDefaults() { + assertEquals("Hello, world.", MySwiftLibrary.greeting()); + assertEquals("Hello, Ana.", MySwiftLibrary.greeting("Ana")); + assertEquals("Hello, Ana!", MySwiftLibrary.greeting("Ana", true)); + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index 3327a608d..103ffb55a 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -141,6 +141,8 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator { // Expand variadic functions into N overloads var expandedAnalysis = analysis expandedAnalysis.expandVariadicOverloads(maxOverloads: config.effectiveMaxVariadicOverloads) + // Default arguments become overloads without the trailing defaulted parameters + expandedAnalysis.expandDefaultArgumentOverloads() self.analysis = expandedAnalysis // Every extracted protocol that also gets a plain Java `interface` diff --git a/Sources/SwiftExtract/AnalysisResult.swift b/Sources/SwiftExtract/AnalysisResult.swift index 259882a96..4f38133ba 100644 --- a/Sources/SwiftExtract/AnalysisResult.swift +++ b/Sources/SwiftExtract/AnalysisResult.swift @@ -47,6 +47,25 @@ public struct AnalysisResult { type.initializers = type.initializers.flatMap { $0.expandingVariadicOverloads(maxOverloads: maxOverloads) } } } + + /// Adds an overload for each trailing defaulted parameter a function can be called without, unless + /// a declared function already has that name and parameter types. Protocol requirements can't have + /// default arguments, so protocols are left as they are. + public mutating func expandDefaultArgumentOverloads() { + self.extractedGlobalFuncs = Self.withDefaultArgumentOverloads(self.extractedGlobalFuncs) + + for type in self.extractedTypes.values where type.swiftNominal.kind != .protocol { + type.methods = Self.withDefaultArgumentOverloads(type.methods) + type.initializers = Self.withDefaultArgumentOverloads(type.initializers) + } + } + + private static func withDefaultArgumentOverloads(_ funcs: [ExtractedFunc]) -> [ExtractedFunc] { + var declared = Set(funcs.map(\.overloadKey)) + return funcs.flatMap { function in + [function] + function.defaultArgumentOverloads().filter { declared.insert($0.overloadKey).inserted } + } + } } // ==== ----------------------------------------------------------------------- diff --git a/Sources/SwiftExtract/ExtractedDecls.swift b/Sources/SwiftExtract/ExtractedDecls.swift index 6b821a815..39903ab1a 100644 --- a/Sources/SwiftExtract/ExtractedDecls.swift +++ b/Sources/SwiftExtract/ExtractedDecls.swift @@ -493,6 +493,35 @@ public final class ExtractedFunc: ExtractedSwiftDecl, CustomStringConvertible { return overloads } + + /// Overloads that leave out this function's trailing defaulted parameters, longest first, for + /// callers without default arguments. Swift fills in the values they leave out. + public func defaultArgumentOverloads() -> [ExtractedFunc] { + let parameters = functionSignature.parameters + var overloads: [ExtractedFunc] = [] + var count = parameters.count + while count > 0, parameters[count - 1].hasDefaultValue { + count -= 1 + var signature = functionSignature + signature.parameters = Array(parameters.prefix(count)) + overloads.append( + ExtractedFunc( + module: module, + swiftDecl: swiftDecl, + name: name, + apiKind: apiKind, + functionSignature: signature, + sourceFilePath: sourceFilePath + ) + ) + } + return overloads + } + + /// Identifies an overload by name and parameter types, as Java does. + var overloadKey: String { + "\(name)(\(functionSignature.parameters.map { "\($0.type)" }.joined(separator: ",")))" + } } extension ExtractedFunc: Hashable { diff --git a/Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift b/Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift new file mode 100644 index 000000000..b03cd03a1 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftExtract +import SwiftJavaConfigurationShared +import Testing + +/// Trailing default arguments become Java overloads that leave them out; Swift fills them in. +@Suite +struct JNIDefaultArgumentsTests { + let source = "public func greet(name: String, excited: Bool = false, times: Int32 = 1) -> String {}" + + @Test + func javaOverloads() throws { + try assertOutput( + input: source, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static java.lang.String greet(java.lang.String name, boolean excited, int times) {", + "public static java.lang.String greet(java.lang.String name, boolean excited) {", + "public static java.lang.String greet(java.lang.String name) {", + "private static native java.lang.String $greet(java.lang.String name);", + ] + ) + } + + @Test + func swiftCallsLeaveTheArgumentsOut() throws { + try assertOutput( + input: source, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + "return SwiftModule.greet(name: String(fromJNI: name, in: environment), excited: Bool(fromJNI: excited, in: environment)).getJNILocalRefValue(in: environment)", + "return SwiftModule.greet(name: String(fromJNI: name, in: environment)).getJNILocalRefValue(in: environment)", + ] + ) + } + + @Test + func initializers() throws { + try assertOutput( + input: """ + public struct Point { + public init(x: Int64 = 0, y: Int64 = 0) {} + } + """, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static Point init(long x, SwiftArena swiftArena) {", + "public static Point init(SwiftArena swiftArena) {", + ] + ) + } + + @Test + func declaredOverloadsWin() throws { + var config = Configuration() + config.swiftModule = "SwiftModule" + let translator = makeSwiftJavaAnalyzer(config: config) + try translator.analyze( + path: "/fake/Fake.swiftinterface", + text: """ + public func f(a: Int64 = 0) {} + public func f() {} + """ + ) + var analysis = translator.result + analysis.expandDefaultArgumentOverloads() + // `f()` is declared, so `f(a:)` adds no overload without `a`. + #expect(analysis.extractedGlobalFuncs.map(\.functionSignature.parameters.count) == [1, 0]) + } + + @Test + func onlyTrailingDefaults() throws { + try assertOutput( + input: "public func f(a: Int64 = 0, b: Int64) {}", + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: ["public static void f(long a, long b) {"], + notExpectedChunks: ["public static void f(long a) {", "public static void f() {"] + ) + } +} From 11219e304b07add4211c2fff57cd0e01ec461ae2 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 18:25:55 +0100 Subject: [PATCH 2/4] Samples: leaving out Swift default arguments from Kotlin Co-Authored-By: Claude Opus 5.5 --- .../KotlinSampleLibrary/Receipts.swift | 20 ++++++++++++++ .../example/kotlin/DefaultArgumentsTest.kt | 27 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift new file mode 100644 index 000000000..f479b6cd3 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// 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 enum Receipts { + /// Defaults the Kotlin caller can leave out, as it would with a Kotlin default. + public static func format(_ amount: Int64, currency: String = "USD", showsSign: Bool = false) -> String { + "\(showsSign && amount > 0 ? "+" : "")\(amount) \(currency)" + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt new file mode 100644 index 000000000..86529ed83 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// 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.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefaultArgumentsTest { + @Test + fun trailingDefaultsCanBeLeftOut() { + assertEquals("5 USD", Receipts.format(5)) + assertEquals("5 EUR", Receipts.format(5, "EUR")) + assertEquals("+5 EUR", Receipts.format(5, "EUR", true)) + } +} From 7b2e49e4c97eba70523ed4e618fa65d9b7370eae Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 18:47:08 +0100 Subject: [PATCH 3/4] JNI: default-argument overloads with closures compile and resolve in Kotlin Found with a view model's `Callbacks` whose closures all have defaults: - Overloads share their closure parameters' Swift `@JavaInterface` wrappers and Java helper class; each is now printed once. - With a result that may take a `SwiftArena`, `init(onOpen, SwiftArena)` was ambiguous with `init(onOpen, onClose)` for Kotlin lambdas, which convert to `SwiftArena`. Such an overload is left out when the next parameter is a closure and the longer overload exists. - A Java helper class gets a private constructor, so Kotlin's `T.init()` isn't ambiguous with constructing the helper class `T.init`. Co-Authored-By: Claude Opus 5.5 --- ...avaGenerator+EscapingClosureWrapJava.swift | 3 ++ ...t2JavaGenerator+JavaBindingsPrinting.swift | 7 +++ .../JNI/JNISwift2JavaGenerator.swift | 3 ++ Sources/SwiftExtract/ExtractedDecls.swift | 33 ++++++++++++ .../JNI/JNIClosureTests.swift | 1 + .../JNI/JNICodegenFixesTests.swift | 1 + .../JNI/JNIDefaultArgumentsTests.swift | 54 +++++++++++++++++++ .../JNI/JNIEscapingClosureTests.swift | 3 ++ 8 files changed, 105 insertions(+) diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift index 586f3ebef..e9de9783f 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+EscapingClosureWrapJava.swift @@ -117,6 +117,9 @@ extension JNISwift2JavaGenerator { _ printer: inout SwiftPrinter, _ closureTy: SyntheticEscapingClosureFunctionType, ) { + guard printedEscapingClosureWrappers.insert(closureTy.javaInterfaceName).inserted else { + return + } printer.printBraceBlock( """ @JavaInterface("\(closureTy.javaBinaryName)") diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift index 6b072c619..2e2ec382e 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaBindingsPrinting.swift @@ -966,12 +966,19 @@ extension JNISwift2JavaGenerator { { return } + // Overloads of a method share its helper class; the first, with every parameter, prints it. + guard printedJavaClosureHelperClasses.insert("\(translated.parentName).\(translated.name)").inserted else { + return + } printer.printBraceBlock( """ public static class \(translated.name) """ ) { printer in + // Only holds interfaces. Without a public constructor, Kotlin's `Type.method()` can't also mean + // `Type.method` constructed, when the method takes no arguments. + printer.print("private \(translated.name)() {}") for functionType in translated.functionTypes { if KnownJavaFunctionalInterface.find(functionType.swiftType) != nil { continue diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index 103ffb55a..6414b60dc 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -52,6 +52,9 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator { /// Jextracted types that already have a minimal JavaKit class wrapper printed for closures. var printedMinimalJavaClassWrappers: Set = [] + /// Overloads for default arguments share their closure parameters' wrappers and Java interfaces. + var printedEscapingClosureWrappers: Set = [] + var printedJavaClosureHelperClasses: Set = [] /// Accumulates every ``@_cdecl`` symbol name emitted during thunk printing. /// Written to a linker version script after generation when diff --git a/Sources/SwiftExtract/ExtractedDecls.swift b/Sources/SwiftExtract/ExtractedDecls.swift index 39903ab1a..ead44f493 100644 --- a/Sources/SwiftExtract/ExtractedDecls.swift +++ b/Sources/SwiftExtract/ExtractedDecls.swift @@ -496,12 +496,24 @@ public final class ExtractedFunc: ExtractedSwiftDecl, CustomStringConvertible { /// Overloads that leave out this function's trailing defaulted parameters, longest first, for /// callers without default arguments. Swift fills in the values they leave out. + /// + /// A method whose result may need a `SwiftArena` also gets an overload ending in one, which a + /// Kotlin lambda converts to. So an overload is left out when the parameter after its last one is + /// a closure and the overload with that closure exists: `f(a, SwiftArena)` would be ambiguous with + /// `f(a, closure)`. public func defaultArgumentOverloads() -> [ExtractedFunc] { let parameters = functionSignature.parameters + let mayTakeArena = apiKind == .initializer || Self.resultMayNeedArena(functionSignature.result.type) var overloads: [ExtractedFunc] = [] var count = parameters.count + var longerOverloadExists = true while count > 0, parameters[count - 1].hasDefaultValue { count -= 1 + if mayTakeArena, longerOverloadExists, case .function = parameters[count].type { + longerOverloadExists = false + continue + } + longerOverloadExists = true var signature = functionSignature signature.parameters = Array(parameters.prefix(count)) overloads.append( @@ -518,6 +530,27 @@ public final class ExtractedFunc: ExtractedSwiftDecl, CustomStringConvertible { return overloads } + /// Whether a result may be returned through a `SwiftArena`: anything but primitives, strings and + /// `Void`, or optionals and arrays of them. + private static func resultMayNeedArena(_ type: SwiftType) -> Bool { + switch type { + case .nominal(let nominal): + switch nominal.asKnownType { + case .bool, .int, .uint, .int8, .uint8, .int16, .uint16, .int32, .uint32, .int64, .uint64, .float, .double, + .string: + return false + case .optional(let wrapped), .array(let wrapped): + return resultMayNeedArena(wrapped) + default: + return true + } + case .tuple(let elements): + return elements.contains { resultMayNeedArena($0.type) } + default: + return true + } + } + /// Identifies an overload by name and parameter types, as Java does. var overloadKey: String { "\(name)(\(functionSignature.parameters.map { "\($0.type)" }.joined(separator: ",")))" diff --git a/Tests/JExtractSwiftTests/JNI/JNIClosureTests.swift b/Tests/JExtractSwiftTests/JNI/JNIClosureTests.swift index aa5f1fa6e..92ee07a89 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIClosureTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIClosureTests.swift @@ -1213,6 +1213,7 @@ struct JNIClosureTests { expectedChunks: [ """ public static class closureWithArgumentsAndReturn { + private closureWithArgumentsAndReturn() {} /** Corresponds to the Swift closure parameter of type {@code (Int64, Bool) -> Int64}. */ @FunctionalInterface public interface closure { diff --git a/Tests/JExtractSwiftTests/JNI/JNICodegenFixesTests.swift b/Tests/JExtractSwiftTests/JNI/JNICodegenFixesTests.swift index 94cadf7a8..4e58c861d 100644 --- a/Tests/JExtractSwiftTests/JNI/JNICodegenFixesTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNICodegenFixesTests.swift @@ -32,6 +32,7 @@ struct JNICodegenFixesTests { expectedChunks: [ """ public static class apply { + private apply() {} /** Corresponds to the Swift closure parameter of type {@code (Int) -> Int}. */ @FunctionalInterface public interface f { diff --git a/Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift b/Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift index b03cd03a1..6d33d945e 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// +import CodePrinting import JExtractSwiftLib import SwiftExtract import SwiftJavaConfigurationShared @@ -99,4 +100,57 @@ struct JNIDefaultArgumentsTests { notExpectedChunks: ["public static void f(long a) {", "public static void f() {"] ) } + + let callbacks = """ + public struct Callbacks { + public init(onOpen: @escaping (Int64) -> Void = { _ in }, onClose: @escaping () -> Void = {}) {} + } + public func run(times: Int64, onDone: @escaping () -> Void = {}) {} + """ + + /// An initializer's result may take a `SwiftArena`, which a Kotlin lambda converts to, so + /// `init(onOpen, SwiftArena)` would be ambiguous with `init(onOpen, onClose)`. + @Test + func closureOverloadsThatWouldBeAmbiguousWithAnArena() throws { + var config = Configuration() + config.swiftModule = "SwiftModule" + let translator = makeSwiftJavaAnalyzer(config: config) + try translator.analyze(path: "/fake/Fake.swiftinterface", text: callbacks) + var analysis = translator.result + analysis.expandDefaultArgumentOverloads() + let initializers = try #require(analysis.extractedTypes.values.first?.initializers) + #expect(initializers.map(\.functionSignature.parameters.count) == [2, 0]) + // No arena for a `Void` result, so every overload is kept. + #expect(analysis.extractedGlobalFuncs.map(\.functionSignature.parameters.count) == [2, 1]) + } + + @Test + func overloadsShareClosureWrappers() throws { + try assertOutput( + input: callbacks, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: ["public struct JavaSwiftModule_run_onDone"] + ) + var config = Configuration() + config.swiftModule = "SwiftModule" + let translator = makeSwiftJavaAnalyzer(config: config) + try translator.analyze(path: "/fake/Fake.swiftinterface", text: callbacks) + let generator = JNISwift2JavaGenerator( + config: config, + translator: translator, + javaPackage: "com.example.swift", + swiftOutputDirectory: "/fake", + javaOutputDirectory: "/fake", + javaClassLookupTable: [:], + moduleJavaPackages: [:] + ) + var swiftPrinter = SwiftPrinter(mode: .accumulateAll) + try generator.writeSwiftThunkSources(&swiftPrinter) + var javaPrinter = JavaPrinter(mode: .accumulateAll) + try generator.writeExportedJavaSources(&javaPrinter) + #expect(swiftPrinter.contents.components(separatedBy: "public struct JavaSwiftModule_run_onDone").count == 2) + #expect(javaPrinter.contents.components(separatedBy: "public static class run").count == 2) + } } diff --git a/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift index b6ca93af7..5ed30a1c5 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIEscapingClosureTests.swift @@ -57,6 +57,7 @@ struct JNIEscapingClosureTests { expectedChunks: [ """ public static class setCallback { + private setCallback() {} /** Corresponds to the Swift closure parameter of type {@code @escaping () -> Void}. */ @FunctionalInterface public interface callback { @@ -93,6 +94,7 @@ struct JNIEscapingClosureTests { expectedChunks: [ """ public static class delayedExecution { + private delayedExecution() {} /** Corresponds to the Swift closure parameter of type {@code @escaping (Int64) -> Int64}. */ @FunctionalInterface public interface closure { @@ -146,6 +148,7 @@ struct JNIEscapingClosureTests { expectedChunks: [ """ public static class setCallback { + private setCallback() {} /** Corresponds to the Swift closure parameter of type {@code @escaping () -> Void}. */ @FunctionalInterface public interface callback { From d0f23e034ec006f37a57ed8fce6c231ffc6afc98 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 18:47:09 +0100 Subject: [PATCH 4/4] Samples: defaulted callbacks passed from Kotlin, or left out Co-Authored-By: Claude Opus 5.5 --- .../KotlinSampleLibrary/Receipts.swift | 22 +++++++++++++++++++ .../example/kotlin/DefaultArgumentsTest.kt | 13 +++++++++++ 2 files changed, 35 insertions(+) diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift index f479b6cd3..3e99e920f 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift @@ -18,3 +18,25 @@ public enum Receipts { "\(showsSign && amount > 0 ? "+" : "")\(amount) \(currency)" } } + +/// Callbacks with defaults, like a view model's: Kotlin passes both or neither. +public struct ReceiptPrinter: Sendable { + private let onPrinted: @Sendable (String) -> Void + private let onFailed: @Sendable () -> Void + + public init( + onPrinted: @escaping @Sendable (String) -> Void = { _ in }, + onFailed: @escaping @Sendable () -> Void = {} + ) { + self.onPrinted = onPrinted + self.onFailed = onFailed + } + + public func print(_ amount: Int64) { + if amount >= 0 { + onPrinted(Receipts.format(amount)) + } else { + onFailed() + } + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt index 86529ed83..1b0ebfc7c 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt @@ -16,6 +16,7 @@ package com.example.kotlin import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class DefaultArgumentsTest { @Test @@ -24,4 +25,16 @@ class DefaultArgumentsTest { assertEquals("5 EUR", Receipts.format(5, "EUR")) assertEquals("+5 EUR", Receipts.format(5, "EUR", true)) } + + @Test + fun defaultedCallbacks() { + val lines = mutableListOf() + var failed = false + val printer = ReceiptPrinter.init({ lines += it }, { failed = true }) + printer.print(5) + printer.print(-1) + assertEquals(listOf("5 USD"), lines) + assertTrue(failed) + ReceiptPrinter.init().print(5) + } }