diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift new file mode 100644 index 000000000..3e99e920f --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Receipts.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// 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)" + } +} + +/// 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 new file mode 100644 index 000000000..1b0ebfc7c --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/DefaultArgumentsTest.kt @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import kotlin.test.assertTrue + +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)) + } + + @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) + } +} 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+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 3327a608d..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 @@ -141,6 +144,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..ead44f493 100644 --- a/Sources/SwiftExtract/ExtractedDecls.swift +++ b/Sources/SwiftExtract/ExtractedDecls.swift @@ -493,6 +493,68 @@ 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. + /// + /// 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( + ExtractedFunc( + module: module, + swiftDecl: swiftDecl, + name: name, + apiKind: apiKind, + functionSignature: signature, + sourceFilePath: sourceFilePath + ) + ) + } + 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: ",")))" + } } extension ExtractedFunc: Hashable { 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 new file mode 100644 index 000000000..6d33d945e --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIDefaultArgumentsTests.swift @@ -0,0 +1,156 @@ +//===----------------------------------------------------------------------===// +// +// 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 CodePrinting +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() {"] + ) + } + + 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 {