From acd2c8b463b8ee7cef878d5447b76cb0d078b988 Mon Sep 17 00:00:00 2001 From: Himeshi Date: Mon, 26 Oct 2026 16:04:34 +0900 Subject: [PATCH] JNI: support Swift Range parameters and results --- .../Sources/MySwiftLibrary/Range.swift | 72 ++++++++++ .../com/example/swift/SwiftRangeTest.java | 127 ++++++++++++++++++ .../Common/JavaTypeAnnotations.swift | 3 + .../JNI/JNIJavaTypeTranslator.swift | 6 +- ...Generator+InterfaceWrapperGeneration.swift | 4 +- ...ISwift2JavaGenerator+JavaTranslation.swift | 20 +++ ...wift2JavaGenerator+NativeTranslation.swift | 32 +++++ ...ift2JavaGenerator+SwiftThunkPrinting.swift | 14 +- .../JavaTypes/JavaType+Collections.swift | 5 + .../SwiftTypes/SwiftKnownModules.swift | 2 + .../SwiftTypes/SwiftKnownTypeDecls.swift | 6 + .../BridgedValues/SwiftRangeBridge.swift | 78 +++++++++++ .../Documentation.docc/FeaturesJextract.md | 28 ++++ .../org/swift/swiftkit/core/SwiftRange.java | 55 ++++++++ .../JNI/JNIRangeTests.swift | 116 ++++++++++++++++ .../SwiftKnownModuleTests.swift | 2 +- 16 files changed, 563 insertions(+), 7 deletions(-) create mode 100644 Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Range.swift create mode 100644 Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/SwiftRangeTest.java create mode 100644 Sources/SwiftJava/BridgedValues/SwiftRangeBridge.swift create mode 100644 SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftRange.java create mode 100644 Tests/JExtractSwiftTests/JNI/JNIRangeTests.swift diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Range.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Range.swift new file mode 100644 index 000000000..eb57a41ad --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/Range.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// +// 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 func makeInt64Range() -> Range { + -10..<20 +} + +public func int64Range(_ range: Range) -> Range { + range +} + +public func platformIntRange(_ range: Range) -> Range { + range +} + +public func uint64Range(_ range: Range) -> Range { + range +} + +public func int32Range(_ range: Range) -> Range { + range +} + +public func int64RangeContains(_ range: Range, value: Int64) -> Bool { + range.contains(value) +} + +public func acceptInt64Range(_ range: Range) { +} + +public func multipleRanges(_ first: Range, _ second: Range) -> Int64 { + first.lowerBound + second.lowerBound +} + +public func doubleRange(_ range: Range) -> Range { + range +} + +public func stringRange(_ range: Range) -> Range { + range +} + +public func stringRangeIsEmpty(_ range: Range) -> Bool { + range.isEmpty +} + +public struct RangeBound: Comparable { + public let value: Int64 + + public init(value: Int64) { + self.value = value + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.value < rhs.value + } +} + +public func customBoundRange(_ range: Range) -> Range { + range +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/SwiftRangeTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/SwiftRangeTest.java new file mode 100644 index 000000000..2bbf795b1 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/SwiftRangeTest.java @@ -0,0 +1,127 @@ +//===----------------------------------------------------------------------===// +// +// 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 org.swift.swiftkit.core.SwiftRange; + +import static org.junit.jupiter.api.Assertions.*; + +public class SwiftRangeTest { + @Test + void returnRange() { + SwiftRange range = MySwiftLibrary.makeInt64Range(); + assertEquals(-10L, range.getLowerBound()); + assertEquals(20L, range.getUpperBound()); + } + + @Test + void roundTripAndHalfOpenBounds() { + SwiftRange input = new SwiftRange<>(-5L, 10L); + SwiftRange result = MySwiftLibrary.int64Range(input); + assertEquals(input.getLowerBound(), result.getLowerBound()); + assertEquals(input.getUpperBound(), result.getUpperBound()); + assertTrue(MySwiftLibrary.int64RangeContains(input, -5L)); + assertFalse(MySwiftLibrary.int64RangeContains(input, 10L)); + } + + @Test + void emptyRange() { + SwiftRange range = MySwiftLibrary.int64Range(new SwiftRange<>(7L, 7L)); + assertEquals(7L, range.getLowerBound()); + assertEquals(7L, range.getUpperBound()); + assertFalse(MySwiftLibrary.int64RangeContains(range, 7L)); + } + + @Test + void integerBounds() { + SwiftRange range = MySwiftLibrary.int32Range(new SwiftRange<>(Integer.MIN_VALUE, Integer.MAX_VALUE)); + assertEquals(Integer.MIN_VALUE, range.getLowerBound()); + assertEquals(Integer.MAX_VALUE, range.getUpperBound()); + } + + @Test + void platformAndUnsignedIntegerBounds() { + SwiftRange signed = MySwiftLibrary.platformIntRange(new SwiftRange<>(-1L, 5L)); + assertEquals(-1L, signed.getLowerBound()); + assertEquals(5L, signed.getUpperBound()); + // UInt64.max is represented by Java's signed long bit pattern -1. + SwiftRange unsigned = MySwiftLibrary.uint64Range(new SwiftRange<>(0L, -1L)); + assertEquals(0L, unsigned.getLowerBound()); + assertEquals(-1L, unsigned.getUpperBound()); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.uint64Range(new SwiftRange<>(-1L, 0L))); + } + + @Test + void reversedBoundsThrowForObjectPrimitiveAndVoidReturns() { + SwiftRange invalid = new SwiftRange<>(10L, 5L); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.int64Range(invalid)); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.int64RangeContains(invalid, 7L)); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.acceptInt64Range(invalid)); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.multipleRanges(invalid, invalid)); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.multipleRanges(new SwiftRange<>(0L, 1L), invalid)); + assertEquals(-10L, MySwiftLibrary.makeInt64Range().getLowerBound()); + } + + @Test + void nullRangeAndNullBoundsThrow() { + assertThrows(NullPointerException.class, () -> MySwiftLibrary.int64Range(null)); + assertThrows(NullPointerException.class, () -> new SwiftRange(null, 1L)); + assertThrows(NullPointerException.class, () -> new SwiftRange(0L, null)); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void erasedBoundTypesAreChecked() { + SwiftRange invalid = new SwiftRange<>("a", "z"); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.int64Range(invalid)); + } + + @Test + void floatingPointBounds() { + SwiftRange range = MySwiftLibrary.doubleRange(new SwiftRange<>(-0.5, 1.25)); + assertEquals(-0.5, range.getLowerBound()); + assertEquals(1.25, range.getUpperBound()); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.doubleRange(new SwiftRange<>(Double.NaN, 1.0))); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.doubleRange(new SwiftRange<>(0.0, Double.NaN))); + } + + @Test + void stringsUseSwiftOrdering() { + // Swift treats canonically equivalent strings as equal (an empty range), + // whereas Java compares their UTF-16 code units. + String lower = "\u00E9"; + String upper = "e\u0301"; + assertTrue(lower.compareTo(upper) > 0); + SwiftRange result = MySwiftLibrary.stringRange(new SwiftRange<>(lower, upper)); + assertEquals(lower, result.getLowerBound()); + assertEquals(upper, result.getUpperBound()); + assertTrue(MySwiftLibrary.stringRangeIsEmpty(result)); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.stringRange(new SwiftRange<>("z", "a"))); + } + + @Test + void swiftComparableBoundsDoNotRequireJavaComparable() { + try (var arena = SwiftArena.ofConfined()) { + RangeBound lower = RangeBound.init(1L, arena); + RangeBound upper = RangeBound.init(5L, arena); + SwiftRange result = MySwiftLibrary.customBoundRange(new SwiftRange<>(lower, upper)); + assertEquals(1L, result.getLowerBound().getValue()); + assertEquals(5L, result.getUpperBound().getValue()); + assertThrows(IllegalArgumentException.class, () -> MySwiftLibrary.customBoundRange(new SwiftRange<>(upper, lower))); + } + } +} diff --git a/Sources/JExtractSwiftLib/Common/JavaTypeAnnotations.swift b/Sources/JExtractSwiftLib/Common/JavaTypeAnnotations.swift index 2c733cb0e..ee35855b0 100644 --- a/Sources/JExtractSwiftLib/Common/JavaTypeAnnotations.swift +++ b/Sources/JExtractSwiftLib/Common/JavaTypeAnnotations.swift @@ -29,6 +29,9 @@ func getJavaTypeAnnotations(swiftType: SwiftType, config: Configuration) -> [Jav case .array(let element): // check recursively for [[UInt8]] etc return getJavaTypeAnnotations(swiftType: element, config: config) + case .range(let bound): + return getJavaTypeAnnotations(swiftType: bound, config: config) + case .set(let element) where element.isUnsignedInteger: return [JavaAnnotation.unsigned] case .set(let element): diff --git a/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift b/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift index 2019ba48a..48cf02a93 100644 --- a/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNIJavaTypeTranslator.swift @@ -52,7 +52,7 @@ enum JNIJavaTypeTranslator { .essentialsData, .essentialsDataProtocol, .array, .dictionary, - .set, + .set, .range, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, .foundationURL, .essentialsURL: @@ -80,7 +80,7 @@ enum JNIJavaTypeTranslator { .essentialsData, .essentialsDataProtocol, .array, .dictionary, - .set, + .set, .range, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, .foundationURL, .essentialsURL: @@ -108,7 +108,7 @@ enum JNIJavaTypeTranslator { .essentialsData, .essentialsDataProtocol, .array, .dictionary, - .set, + .set, .range, .foundationDate, .essentialsDate, .foundationUUID, .essentialsUUID, .foundationURL, .essentialsURL: diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift index 3f3e8ba4e..8c2197e45 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+InterfaceWrapperGeneration.swift @@ -256,7 +256,7 @@ extension JNISwift2JavaGenerator { case .nominal(let nominalType): if let knownType = nominalType.nominalTypeDecl.knownTypeKind { switch knownType { - case .optional, .array, .dictionary, .set: + case .optional, .array, .dictionary, .set, .range: throw JavaTranslationError.unsupportedSwiftType(known: .array(elementType)) default: break @@ -350,7 +350,7 @@ extension JNISwift2JavaGenerator { case .nominal(let nominalType): if let knownType = nominalType.nominalTypeDecl.knownTypeKind { switch knownType { - case .optional, .array, .dictionary, .set: + case .optional, .array, .dictionary, .set, .range: throw JavaTranslationError.unsupportedSwiftType(known: .array(elementType)) default: break diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index d2ddfc994..12ef6b99a 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -504,6 +504,13 @@ extension JNISwift2JavaGenerator { genericRequirements: genericRequirements, ) + case .range(let bound): + let boundJavaType = try translateGenericTypeParameter(bound, genericParameters: genericParameters, genericRequirements: genericRequirements) + return TranslatedParameter( + parameter: JavaParameter(name: parameterName, type: .swiftRange(boundJavaType)), + conversion: .requireNonNull(.placeholder, message: "\(parameterName) must not be null"), + ) + case .set(let elementType): return try translateSetParameter( elementType: elementType, @@ -970,6 +977,16 @@ extension JNISwift2JavaGenerator { genericRequirements: genericRequirements, ) + case .range(let bound): + let boundJavaType = try translateGenericTypeParameter(bound, genericParameters: genericParameters, genericRequirements: genericRequirements) + let rangeType = JavaType.swiftRange(boundJavaType) + return TranslatedResult( + javaType: rangeType, + nativeJavaType: .class(package: "org.swift.swiftkit.core", name: "SwiftRange"), + outParameters: [], + conversion: .placeholder + ) + case .set(let element): return try translateSetResult( elementType: element, @@ -1159,6 +1176,9 @@ extension JNISwift2JavaGenerator { ) return .swiftDictionaryMap(keyJavaType, valueJavaType) + case .range(let bound): + return .swiftRange(try translateGenericTypeParameter(bound, genericParameters: genericParameters, genericRequirements: genericRequirements)) + case .set(let element): let elementJavaType = try translateGenericTypeParameter( element, diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 4c87fa6a2..c89715bfb 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -147,6 +147,19 @@ extension JNISwift2JavaGenerator { parameterName: parameterName ) + case .range(let bound): + let bridgeType = try bridgeTypeName(for: bound) + return NativeParameter( + parameters: [JavaParameter(name: parameterName, type: .class(package: "org.swift.swiftkit.core", name: "SwiftRange"))], + conversion: .constant("\(JNISwift2JavaGenerator.indirectVariableName(for: parameterName))"), + indirectConversion: .method( + .constant("SwiftRangeBridge<\(bridgeType)>"), + function: "fromJavaObject", + arguments: [(nil, .placeholder), ("in", .constant("environment"))] + ), + conversionCheck: .validRange + ) + case .set(let element): return try translateSetParameter( elementType: element, @@ -350,6 +363,10 @@ extension JNISwift2JavaGenerator { genericParameters: genericParameters, genericRequirements: genericRequirements ) + // A tuple cannot currently propagate an element's validation step. + if case .validRange = elementNative.conversionCheck { + throw JavaTranslationError.unsupportedSwiftType(element.type) + } allJNIParameters.append(contentsOf: elementNative.parameters) elementConversions.append((label: element.label, conversion: elementNative.conversion)) } @@ -812,6 +829,18 @@ extension JNISwift2JavaGenerator { resultName: resultName ) + case .range(let bound): + let bridgeType = try bridgeTypeName(for: bound) + return NativeResult( + javaType: .class(package: "org.swift.swiftkit.core", name: "SwiftRange"), + conversion: .method( + .constant("SwiftRangeBridge<\(bridgeType)>"), + function: "toJavaObject", + arguments: [(nil, .placeholder), ("in", .constant("environment"))] + ), + outParameters: [] + ) + case .set(let elementType): return try translateSetResult( elementType: elementType, @@ -2155,10 +2184,13 @@ extension JNISwift2JavaGenerator { enum NativeSwiftConversionCheck { case check32BitIntOverflow(typeWithMinAndMax: SwiftType) + case validRange // Returns the check string func render(_ printer: inout SwiftPrinter, _ placeholder: String) -> String { switch self { + case .validRange: + return "\(placeholder) != nil" case .check32BitIntOverflow(let minMaxSource): return "\(placeholder) >= \(minMaxSource).min && \(placeholder) <= \(minMaxSource).max" } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index efad8cc35..90cb5e51a 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -722,6 +722,18 @@ extension JNISwift2JavaGenerator { let lowered = parameter.conversion.render(&printer, javaParameterName) arguments.append(lowered) + // Range conversion may post a Java exception. Stop before converting any + // subsequent arguments or calling Swift with invalid bounds. + if case .validRange = parameter.conversionCheck { + let name = JNISwift2JavaGenerator.indirectVariableName(for: javaParameterName) + let value = parameter.indirectConversion!.render(&printer, javaParameterName) + printer.print("let \(name) = \(value)") + printer.printGuardBlock("let \(name) = \(name)") { printer in + printer.print(dummyReturn(for: nativeSignature)) + } + continue + } + parameter.indirectConversion.flatMap { indirectVariables.append((javaParameterName, $0.render(&printer, javaParameterName))) } @@ -734,7 +746,7 @@ extension JNISwift2JavaGenerator { JNISwift2JavaGenerator.indirectVariableName(for: javaParameterName), ) ) - case nil: + case .validRange, nil: break } } diff --git a/Sources/JExtractSwiftLib/JavaTypes/JavaType+Collections.swift b/Sources/JExtractSwiftLib/JavaTypes/JavaType+Collections.swift index 18f59fa4d..acca21bdc 100644 --- a/Sources/JExtractSwiftLib/JavaTypes/JavaType+Collections.swift +++ b/Sources/JExtractSwiftLib/JavaTypes/JavaType+Collections.swift @@ -16,6 +16,11 @@ import SwiftJavaJNICore extension JavaType { + /// A half-open Swift range represented by its bounds on the Java heap. + static func swiftRange(_ bound: JavaType) -> JavaType { + .class(package: "org.swift.swiftkit.core", name: "SwiftRange", typeParameters: [bound.boxedType]) + } + /// The description of the type org.swift.swiftkit.core.collections.SwiftDictionaryMap static func swiftDictionaryMap(_ K: JavaType, _ V: JavaType) -> JavaType { .class(package: "org.swift.swiftkit.core.collections", name: "SwiftDictionaryMap", typeParameters: [K.boxedType, V.boxedType]) diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift index d3cf9ffa8..cd240fa9d 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift @@ -104,6 +104,8 @@ private let swiftSourceFile: SourceFileSyntax = """ public struct Set {} + public struct Range {} + public protocol Error {} // FIXME: Support 'typealias Void = ()' diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift b/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift index bcc167f03..60aab9fa6 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownTypeDecls.swift @@ -43,6 +43,7 @@ public enum SwiftKnownType: Equatable { case array(_ element: SwiftType) case dictionary(_ key: SwiftType, _ value: SwiftType) case set(_ element: SwiftType) + case range(_ bound: SwiftType) // Foundation case foundationDataProtocol @@ -102,6 +103,9 @@ public enum SwiftKnownType: Equatable { case .set: guard let arg = genericArguments?.first else { return nil } self = .set(arg) + case .range: + guard let arg = genericArguments?.first else { return nil } + self = .range(arg) case .foundationDataProtocol: self = .foundationDataProtocol case .essentialsDataProtocol: self = .essentialsDataProtocol case .foundationData: self = .foundationData @@ -145,6 +149,7 @@ public enum SwiftKnownType: Equatable { case .array: .array case .dictionary: .dictionary case .set: .set + case .range: .range case .foundationDataProtocol: .foundationDataProtocol case .essentialsDataProtocol: .essentialsDataProtocol case .foundationData: .foundationData @@ -189,6 +194,7 @@ public enum SwiftKnownTypeDeclKind: String, Hashable { case array = "Swift.Array" case dictionary = "Swift.Dictionary" case set = "Swift.Set" + case range = "Swift.Range" // Foundation case foundationDataProtocol = "Foundation.DataProtocol" diff --git a/Sources/SwiftJava/BridgedValues/SwiftRangeBridge.swift b/Sources/SwiftJava/BridgedValues/SwiftRangeBridge.swift new file mode 100644 index 000000000..1ab348a25 --- /dev/null +++ b/Sources/SwiftJava/BridgedValues/SwiftRangeBridge.swift @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftJavaJNICore + +/// Bridges a half-open Swift range by copying its bounds into a Java `SwiftRange`. +/// +/// Bounds are compared in Swift, rather than using Java's `Comparable`, since +/// the two languages can order the same values differently (notably strings). +public enum SwiftRangeBridge where BoundBridge.SwiftType: Comparable { + public typealias SwiftType = Range + + /// Copy the bounds to a Java object. The result is a JNI local reference. + public static func toJavaObject(_ value: SwiftType, in environment: JNIEnvironment) -> jobject? { + guard let rangeClass = environment.interface.FindClass(environment, "org/swift/swiftkit/core/SwiftRange") else { return nil } + defer { environment.interface.DeleteLocalRef(environment, rangeClass) } + guard let initializer = environment.interface.GetMethodID(environment, rangeClass, "", "(Ljava/lang/Object;Ljava/lang/Object;)V") else { return nil } + guard let lower = BoundBridge.toJavaObject(value.lowerBound, in: environment), + let upper = BoundBridge.toJavaObject(value.upperBound, in: environment) + else { return nil } + var arguments = [jvalue(), jvalue()] + arguments[0].l = lower + arguments[1].l = upper + return environment.interface.NewObjectA(environment, rangeClass, initializer, &arguments) + } + + /// Reconstruct a range, returning `nil` with a pending Java exception for invalid input. + /// The caller must return to Java immediately when conversion fails. + public static func fromJavaObject(_ object: jobject?, in environment: JNIEnvironment) -> SwiftType? { + guard let object else { + postException(in: environment, className: "java/lang/NullPointerException", message: "Range must not be null") + return nil + } + guard let rangeClass = environment.interface.FindClass(environment, "org/swift/swiftkit/core/SwiftRange") else { return nil } + defer { environment.interface.DeleteLocalRef(environment, rangeClass) } + guard environment.interface.IsInstanceOf(environment, object, rangeClass) == JNI_TRUE else { + postException(in: environment, className: "java/lang/IllegalArgumentException", message: "Expected SwiftRange") + return nil + } + guard let lowerField = environment.interface.GetFieldID(environment, rangeClass, "lowerBound", "Ljava/lang/Object;"), + let upperField = environment.interface.GetFieldID(environment, rangeClass, "upperBound", "Ljava/lang/Object;") + else { return nil } + let lowerObject = environment.interface.GetObjectField(environment, object, lowerField) + defer { environment.interface.DeleteLocalRef(environment, lowerObject) } + let upperObject = environment.interface.GetObjectField(environment, object, upperField) + defer { environment.interface.DeleteLocalRef(environment, upperObject) } + guard BoundBridge.isJavaObject(lowerObject, in: environment), + BoundBridge.isJavaObject(upperObject, in: environment) + else { + postException(in: environment, className: "java/lang/IllegalArgumentException", message: "Range bounds have an unexpected Java type") + return nil + } + let lower = BoundBridge.fromJavaObject(lowerObject, in: environment) + let upper = BoundBridge.fromJavaObject(upperObject, in: environment) + guard lower <= upper else { + postException(in: environment, className: "java/lang/IllegalArgumentException", message: "Range requires lowerBound <= upperBound") + return nil + } + return lower..` maps to +`org.swift.swiftkit.core.SwiftRange`. For example, `Range` uses +`SwiftRange` and `Range` uses `SwiftRange`. +The lower bound is inclusive and the upper bound is exclusive; equal bounds +represent an empty range. + +```java +SwiftRange range = MySwiftLibrary.int64Range(new SwiftRange<>(-5L, 10L)); +long lower = range.getLowerBound(); +long upper = range.getUpperBound(); +``` + +The bounds are copied into an immutable Java container. The container needs no +`SwiftArena`; bounds that wrap Swift objects still follow those objects' normal +lifetime requirements. Bounds use the existing conversions for primitives, +strings, and jextract-imported types conforming to Swift's `Comparable`. + +When Java passes a range to Swift, its bound types and ordering are checked +before calling the Swift API. Invalid types, reversed bounds, and unordered +floating-point bounds (NaN) throw `IllegalArgumentException`; null ranges or +bounds throw `NullPointerException`. Ordering is checked in Swift, because Java's +`Comparable` can order values differently and Swift wrappers need not implement it. + +This support covers range parameters and results. FFM, `ClosedRange`, partial +ranges, and ranges nested in Optional or collection types are not yet supported. + ### Bytes and buffers There are several ways to pass raw bytes, differing in whether the bytes are diff --git a/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftRange.java b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftRange.java new file mode 100644 index 000000000..1f837220a --- /dev/null +++ b/SwiftKitCore/src/main/java/org/swift/swiftkit/core/SwiftRange.java @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// 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 org.swift.swiftkit.core; + +import java.util.Objects; + +/** + * A half-open Swift range, including its lower bound and excluding its upper bound. + * The bounds live on the Java heap; this container does not own native memory. + * Bounds that wrap Swift objects retain those objects' normal lifetime requirements. + *

+ * Swift validates the bound types and ordering when the range is passed to Swift. + * This class deliberately does not require {@link Comparable}: Swift's ordering + * need not match Java's, and a Swift bound's wrapper need not implement it. + * Equal bounds represent an empty range. + * + * @param the Java representation of the Swift bound type + */ +public final class SwiftRange { + private final T lowerBound; + private final T upperBound; + + /** + * Create a range to pass to Swift. Swift rejects reversed or unordered bounds + * with {@link IllegalArgumentException} before calling the target function. + * + * @throws NullPointerException if either bound is null + */ + public SwiftRange(T lowerBound, T upperBound) { + this.lowerBound = Objects.requireNonNull(lowerBound, "lowerBound must not be null"); + this.upperBound = Objects.requireNonNull(upperBound, "upperBound must not be null"); + } + + /** Return the inclusive lower bound. */ + public T getLowerBound() { + return lowerBound; + } + + /** Return the exclusive upper bound. */ + public T getUpperBound() { + return upperBound; + } +} diff --git a/Tests/JExtractSwiftTests/JNI/JNIRangeTests.swift b/Tests/JExtractSwiftTests/JNI/JNIRangeTests.swift new file mode 100644 index 000000000..d2fe9d440 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIRangeTests.swift @@ -0,0 +1,116 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing + +@Suite +struct JNIRangeTests { + @Test(arguments: [("Int32", "Integer"), ("Int64", "Long"), ("Double", "Double"), ("String", "String")]) + func rangeJavaBindings(bound: String, javaBound: String) throws { + try assertOutput( + input: "public func f(_ range: Range<\(bound)>) -> Range<\(bound)> {}", + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public static org.swift.swiftkit.core.SwiftRange f(org.swift.swiftkit.core.SwiftRange range) { + return SwiftModule.$f(Objects.requireNonNull(range, "range must not be null")); + } + """, + "private static native org.swift.swiftkit.core.SwiftRange $f(org.swift.swiftkit.core.SwiftRange range);", + ], + notExpectedChunks: ["SwiftArena swiftArena"] + ) + } + + @Test + func rangeSwiftThunkChecksBounds() throws { + try assertOutput( + input: "public func f(_ range: Range) -> Range {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + public func Java_com_example_swift_SwiftModule__00024f__Lorg_swift_swiftkit_core_SwiftRange_2(environment: UnsafeMutablePointer!, thisClass: jclass, range: jobject?) -> jobject? { + let range$indirect = SwiftRangeBridge>.fromJavaObject(range, in: environment) + guard let range$indirect = range$indirect else { + return nil + } + return SwiftRangeBridge>.toJavaObject(SwiftModule.f(range$indirect), in: environment) + } + """ + ] + ) + } + + @Test + func rangeArgumentWithVoidResult() throws { + try assertOutput( + input: "public func f(_ range: Range) {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + guard let range$indirect = range$indirect else { + return + } + """, + "SwiftModule.f(range$indirect)", + ] + ) + } + + @Test + func rangeArgumentWithPrimitiveResult() throws { + try assertOutput( + input: "public func f(_ range: Range) -> Int32 {}", + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + guard let range$indirect = range$indirect else { + return 0 + } + """ + ] + ) + } + + @Test(arguments: ["[Range]", "Range?", "(Range, Int64)"]) + func unsupportedRangeContainersAreSkipped(type: String) throws { + try assertOutput( + input: "public func nestedRange(_ value: \(type)) {}", + .jni, + .java, + expectedChunks: [], + notExpectedChunks: ["nestedRange("] + ) + } + + @Test + func ffmDoesNotExportRange() throws { + try assertOutput( + input: "public func rangeOnly(_ value: Range) -> Range {}", + .ffm, + .java, + expectedChunks: [], + notExpectedChunks: ["rangeOnly("] + ) + } +} diff --git a/Tests/SwiftExtractTests/SwiftKnownModuleTests.swift b/Tests/SwiftExtractTests/SwiftKnownModuleTests.swift index 8bd938609..557885724 100644 --- a/Tests/SwiftExtractTests/SwiftKnownModuleTests.swift +++ b/Tests/SwiftExtractTests/SwiftKnownModuleTests.swift @@ -26,7 +26,7 @@ struct SwiftKnownModuleSuite { "UInt", "UInt8", "UInt16", "UInt32", "UInt64", "Float", "Double", "Bool", "String", - "Array", "Dictionary", "Set", "Optional", + "Array", "Dictionary", "Set", "Optional", "Range", "OpaquePointer", ]) func swiftModuleContains(_ typeName: String) throws {