diff --git a/.unacceptablelanguageignore b/.unacceptablelanguageignore index c3f97d3d..e24839c0 100644 --- a/.unacceptablelanguageignore +++ b/.unacceptablelanguageignore @@ -4,4 +4,5 @@ Sources/_Subprocess/Platforms/Subprocess+Linux.swift Sources/_Subprocess/Platforms/Subprocess+Unix.swift Sources/_Subprocess/Teardown.swift Sources/_Subprocess/Subprocess.swift -NOTICE.txt \ No newline at end of file +Sources/SwiftJavaToolLib/AndroidAPILevel.swift +NOTICE.txt diff --git a/Sources/SwiftJavaToolLib/AndroidAPILevel.swift b/Sources/SwiftJavaToolLib/AndroidAPILevel.swift new file mode 100644 index 00000000..2479ea2a --- /dev/null +++ b/Sources/SwiftJavaToolLib/AndroidAPILevel.swift @@ -0,0 +1,137 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 +// +//===----------------------------------------------------------------------===// + +/// Android SDK version codes, mirroring `android.os.Build.VERSION_CODES`. +/// +/// Source: android/platform/frameworks/base, core/java/android/os/Build.java +/// E.g. https://raw.githubusercontent.com/aosp-mirror/platform_frameworks_base/master/core/java/android/os/Build.java (Apache 2.0) +public enum AndroidAPILevel: Int { + /// The original, first, version of Android. Released publicly as Android 1.0 in September 2008. + case BASE = 1 + /// First Android update. Released publicly as Android 1.1 in February 2009. + case BASE_1_1 = 2 + /// Cupcake. Released publicly as Android 1.5 in April 2009. + case CUPCAKE = 3 + /// Donut. Released publicly as Android 1.6 in September 2009. + case DONUT = 4 + /// Eclair. Released publicly as Android 2.0 in October 2009. + case ECLAIR = 5 + /// Eclair 0.1. Released publicly as Android 2.0.1 in December 2009. + case ECLAIR_0_1 = 6 + /// Eclair MR1. Released publicly as Android 2.1 in January 2010. + case ECLAIR_MR1 = 7 + /// Froyo. Released publicly as Android 2.2 in May 2010. + case FROYO = 8 + /// Gingerbread. Released publicly as Android 2.3 in December 2010. + case GINGERBREAD = 9 + /// Gingerbread MR1. Released publicly as Android 2.3.3 in February 2011. + case GINGERBREAD_MR1 = 10 + /// Honeycomb. Released publicly as Android 3.0 in February 2011. + case HONEYCOMB = 11 + /// Honeycomb MR1. Released publicly as Android 3.1 in May 2011. + case HONEYCOMB_MR1 = 12 + /// Honeycomb MR2. Released publicly as Android 3.2 in July 2011. + case HONEYCOMB_MR2 = 13 + /// Ice Cream Sandwich. Released publicly as Android 4.0 in October 2011. + case ICE_CREAM_SANDWICH = 14 + /// Ice Cream Sandwich MR1. Released publicly as Android 4.03 in December 2011. + case ICE_CREAM_SANDWICH_MR1 = 15 + /// Jelly Bean. Released publicly as Android 4.1 in July 2012. + case JELLY_BEAN = 16 + /// Jelly Bean MR1. Released publicly as Android 4.2 in November 2012. + case JELLY_BEAN_MR1 = 17 + /// Jelly Bean MR2. Released publicly as Android 4.3 in July 2013. + case JELLY_BEAN_MR2 = 18 + /// KitKat. Released publicly as Android 4.4 in October 2013. + case KITKAT = 19 + /// KitKat for watches. Released publicly as Android 4.4W in June 2014. + case KITKAT_WATCH = 20 + /// Lollipop. Released publicly as Android 5.0 in November 2014. + case LOLLIPOP = 21 + /// Lollipop MR1. Released publicly as Android 5.1 in March 2015. + case LOLLIPOP_MR1 = 22 + /// Marshmallow. Released publicly as Android 6.0 in October 2015. + case M = 23 + /// Nougat. Released publicly as Android 7.0 in August 2016. + case N = 24 + /// Nougat MR1. Released publicly as Android 7.1 in October 2016. + case N_MR1 = 25 + /// Oreo. Released publicly as Android 8.0 in August 2017. + case O = 26 + /// Oreo MR1. Released publicly as Android 8.1 in December 2017. + case O_MR1 = 27 + /// Pie. Released publicly as Android 9 in August 2018. + case P = 28 + /// Android 10. Released publicly in September 2019. + case Q = 29 + /// Android 11. Released publicly in September 2020. + case R = 30 + /// Android 12. + case S = 31 + /// Android 12L. + case S_V2 = 32 + /// Tiramisu. Android 13. + case TIRAMISU = 33 + /// Upside Down Cake. Android 14. + case UPSIDE_DOWN_CAKE = 34 + /// Vanilla Ice Cream. Android 15. + case VANILLA_ICE_CREAM = 35 + /// Baklava. Android 16 (upcoming, not yet finalized). + case BAKLAVA = 36 + /// Magic version number for a current development build, which has not yet turned into an official release. + case CUR_DEVELOPMENT = 10000 + + /// Human-readable release name for this API level. + public var name: String { + switch self { + case .BASE: "Base" + case .BASE_1_1: "Base 1.1" + case .CUPCAKE: "Cupcake" + case .DONUT: "Donut" + case .ECLAIR: "Eclair" + case .ECLAIR_0_1: "Eclair 0.1" + case .ECLAIR_MR1: "Eclair MR1" + case .FROYO: "Froyo" + case .GINGERBREAD: "Gingerbread" + case .GINGERBREAD_MR1: "Gingerbread MR1" + case .HONEYCOMB: "Honeycomb" + case .HONEYCOMB_MR1: "Honeycomb MR1" + case .HONEYCOMB_MR2: "Honeycomb MR2" + case .ICE_CREAM_SANDWICH: "Ice Cream Sandwich" + case .ICE_CREAM_SANDWICH_MR1: "Ice Cream Sandwich MR1" + case .JELLY_BEAN: "Jelly Bean" + case .JELLY_BEAN_MR1: "Jelly Bean MR1" + case .JELLY_BEAN_MR2: "Jelly Bean MR2" + case .KITKAT: "KitKat" + case .KITKAT_WATCH: "KitKat Watch" + case .LOLLIPOP: "Lollipop" + case .LOLLIPOP_MR1: "Lollipop MR1" + case .M: "Marshmallow" + case .N: "Nougat" + case .N_MR1: "Nougat MR1" + case .O: "Oreo" + case .O_MR1: "Oreo MR1" + case .P: "Pie" + case .Q: "Android 10" + case .R: "Android 11" + case .S: "Android 12" + case .S_V2: "Android 12L" + case .TIRAMISU: "Tiramisu" + case .UPSIDE_DOWN_CAKE: "Upside Down Cake" + case .VANILLA_ICE_CREAM: "Vanilla Ice Cream" + case .BAKLAVA: "Baklava" + case .CUR_DEVELOPMENT: "CUR_DEVELOPMENT" + } + } +} diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index 04526620..a30a4f51 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -441,9 +441,10 @@ extension JavaClassTranslator { // Emit the struct declaration describing the java class. let classOrInterface: String = isInterface ? "JavaInterface" : "JavaClass" let introducer = translateAsClass ? "open class" : "public struct" + let classAvailableAttributes = swiftAvailableAttributes(from: annotations) var classDecl: DeclSyntax = """ - @\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr)) + \(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr)) \(raw: introducer) \(raw: swiftInnermostTypeName)\(raw: genericParameterClause)\(raw: inheritanceClause) { \(raw: members.map { $0.description }.joined(separator: "\n\n")) } @@ -590,18 +591,98 @@ extension JavaClassTranslator { return protocolDecl.formatted(using: translator.format).cast(DeclSyntax.self) } + /// A single Swift attribute derived from a Java annotation. + struct SwiftAttribute { + /// The attribute text, e.g. `@available(*, deprecated)`. + var value: String + + /// The minimum Swift compiler version required to compile this attribute, + /// e.g. `(6, 3)` for `@available(Android ...)`. When non-nil the attribute + /// is wrapped in a `#if compiler(>=…)` block during rendering. + var minimumCompilerVersion: (Int, Int)? = nil + } + + struct SwiftAvailableAttributes { + var attributes: [SwiftAttribute] = [] + + func render() -> String { + if attributes.isEmpty { + return "" + } + var lines: [String] = [] + for attr in attributes { + if let (major, minor) = attr.minimumCompilerVersion { + lines.append("#if compiler(>=\(major).\(minor))") + lines.append(attr.value) + lines.append("#endif") + } else { + lines.append(attr.value) + } + } + return lines.joined(separator: "\n") + "\n" + } + } + + /// Build Swift `@available` attributes from Java annotations on a reflective element. + private func swiftAvailableAttributes(from annotations: [Annotation]) -> SwiftAvailableAttributes { + var result = SwiftAvailableAttributes() + + for annotation in annotations { + guard let annotationClass = annotation.annotationType() else { continue } + + if annotationClass.isKnown(.javaLangDeprecated) { + result.attributes.append(SwiftAttribute(value: "@available(*, deprecated)")) + continue + } + + if annotationClass.isKnown(.androidxRequiresApi) || annotationClass.isKnown(.androidSupportRequiresApi) { + func apiLevelComment(_ level: Int32) -> String { + AndroidAPILevel(rawValue: Int(level)).map { " /* \($0.name) */" } ?? "" + } + + // The annotation proxy exposes api() which returns the resolved integer value. + // Build.VERSION_CODES constants are resolved at compile time by javac. + let apiLevel = try? annotation.as(JavaObject.self) + .dynamicJavaMethodCall(methodName: "api", resultType: Int32.self) + if let apiLevel, apiLevel > 1 { + result.attributes.append( + SwiftAttribute( + value: "@available(Android \(apiLevel)\(apiLevelComment(apiLevel)), *)", + minimumCompilerVersion: (6, 3) + ) + ) + continue + } + + let value = try? annotation.as(JavaObject.self) + .dynamicJavaMethodCall(methodName: "value", resultType: Int32.self) + if let value, value > 1 { + result.attributes.append( + SwiftAttribute( + value: "@available(Android \(value)\(apiLevelComment(value)), *)", + minimumCompilerVersion: (6, 3) + ) + ) + continue + } + } + } + + return result + } + func renderAnnotationExtensions() -> [DeclSyntax] { var extensions: [DeclSyntax] = [] for annotation in annotations { - let annotationName = annotation.annotationType().getName().splitSwiftTypeName().name - if annotationName == "ThreadSafe" || annotationName == "Immutable" { // If we are threadsafe, mark as unchecked Sendable + guard let annotationClass = annotation.annotationType() else { continue } + if annotationClass.isKnown(.threadSafe) || annotationClass.isKnown(.immutable) { extensions.append( """ extension \(raw: swiftTypeName): @unchecked Swift.Sendable { } """ ) - } else if annotationName == "NotThreadSafe" { // If we are _not_ threadsafe, mark sendable unavailable + } else if annotationClass.isKnown(.notThreadSafe) { extensions.append( """ @available(unavailable, *) @@ -625,10 +706,12 @@ extension JavaClassTranslator { let accessModifier = javaConstructor.isPublic ? "public " : "" let convenienceModifier = translateAsClass ? "convenience " : "" let nonoverrideAttribute = translateAsClass ? "@_nonoverride " : "" + let constructorAnnotations = javaConstructor.getDeclaredAnnotations().compactMap(\.self) + let availableAttributes = swiftAvailableAttributes(from: constructorAnnotations) // FIXME: handle generics in constructors return """ - @JavaMethod + \(raw: availableAttributes.render())@JavaMethod \(raw: nonoverrideAttribute)\(raw: accessModifier)\(raw: convenienceModifier)init(\(raw: parametersStr))\(raw: throwsStr) """ } @@ -747,6 +830,10 @@ extension JavaClassTranslator { /// ``` """ + // --- Handle @available attributes from Java annotations (e.g. @Deprecated, @RequiresApi) + let methodAnnotations = javaMethod.getDeclaredAnnotations().compactMap(\.self) + let availableAttributes = swiftAvailableAttributes(from: methodAnnotations) + // Compute the parameters for '@...JavaMethod(...)' let methodAttribute: AttributeSyntax if implementedInSwift { @@ -817,7 +904,7 @@ extension JavaClassTranslator { return """ \(raw: docsString) - \(methodAttribute)\(raw: accessModifier)\(raw: overrideOpt)func \(raw: swiftMethodName)\(raw: genericParameterClauseStr)(\(raw: parametersStr))\(raw: throwsStr)\(raw: resultTypeStr)\(raw: whereClause) + \(raw: availableAttributes.render())\(methodAttribute)\(raw: accessModifier)\(raw: overrideOpt)func \(raw: swiftMethodName)\(raw: genericParameterClauseStr)(\(raw: parametersStr))\(raw: throwsStr)\(raw: resultTypeStr)\(raw: whereClause) \(raw: accessModifier)\(raw: overrideOpt)func \(raw: swiftOptionalMethodName)\(raw: genericParameterClauseStr)(\(raw: parameters.map(\.clause.description).joined(separator: ", ")))\(raw: throwsStr) -> \(raw: resultOptional)\(raw: whereClause) { \(body) @@ -827,7 +914,7 @@ extension JavaClassTranslator { return """ \(raw: docsString) - \(methodAttribute)\(raw: accessModifier)\(raw: overrideOpt)func \(raw: swiftMethodName)\(raw: genericParameterClauseStr)(\(raw: parametersStr))\(raw: throwsStr)\(raw: resultTypeStr)\(raw: whereClause) + \(raw: availableAttributes.render())\(methodAttribute)\(raw: accessModifier)\(raw: overrideOpt)func \(raw: swiftMethodName)\(raw: genericParameterClauseStr)(\(raw: parametersStr))\(raw: throwsStr)\(raw: resultTypeStr)\(raw: whereClause) """ } } @@ -842,6 +929,8 @@ extension JavaClassTranslator { ) let fieldAttribute: AttributeSyntax = javaField.isStatic ? "@JavaStaticField" : "@JavaField" let swiftFieldName = javaField.getName().escapedSwiftName + let fieldAnnotations = javaField.getDeclaredAnnotations().compactMap(\.self) + let availableAttributes = swiftAvailableAttributes(from: fieldAnnotations) if let optionalType = typeName.optionalWrappedType() { let setter = @@ -856,7 +945,7 @@ extension JavaClassTranslator { "" } return """ - \(fieldAttribute)(isFinal: \(raw: javaField.isFinal)) + \(raw: availableAttributes.render())\(fieldAttribute)(isFinal: \(raw: javaField.isFinal)) public var \(raw: swiftFieldName): \(raw: typeName) @@ -868,7 +957,7 @@ extension JavaClassTranslator { """ } else { return """ - \(fieldAttribute)(isFinal: \(raw: javaField.isFinal)) + \(raw: availableAttributes.render())\(fieldAttribute)(isFinal: \(raw: javaField.isFinal)) public var \(raw: swiftFieldName): \(raw: typeName) """ } diff --git a/Sources/SwiftJavaToolLib/KnownJavaAnnotation.swift b/Sources/SwiftJavaToolLib/KnownJavaAnnotation.swift new file mode 100644 index 00000000..d1f438e3 --- /dev/null +++ b/Sources/SwiftJavaToolLib/KnownJavaAnnotation.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// 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 JavaLangReflect +import SwiftJava + +/// Well-known Java annotation types that the Swift wrapper generator handles +/// during code generation. +public enum KnownJavaAnnotation: String { + case javaLangDeprecated = "java.lang.Deprecated" + case androidxRequiresApi = "androidx.annotation.RequiresApi" + case androidSupportRequiresApi = "android.support.annotation.RequiresApi" + + // Thread-safety annotations (may originate from javax.annotation.concurrent + // or net.jcip.annotations; matched by simple name). If someone made their own + // but used the same names, we just assume they meant the same meaning -- it'd + // be wild to call an annotation ThreadSafe and not have it mean that :-) + case threadSafe = "ThreadSafe" + case immutable = "Immutable" + case notThreadSafe = "NotThreadSafe" + + /// Whether this case should be matched by simple (unqualified) class name + /// rather than the fully-qualified name. + private var matchesBySimpleName: Bool { + switch self { + case .threadSafe, .immutable, .notThreadSafe: + return true + default: + return false + } + } + + /// Check whether the given fully-qualified annotation class name matches + /// this known annotation. + func matches(fullyQualifiedName fqn: String) -> Bool { + if matchesBySimpleName { + return fqn.splitSwiftTypeName().name == rawValue + } + return fqn == rawValue + } +} + +extension JavaClass { + /// Check whether this annotation class matches a known annotation type. + func isKnown(_ known: KnownJavaAnnotation) -> Bool { + known.matches(fullyQualifiedName: self.getName()) + } +} diff --git a/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift b/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift new file mode 100644 index 00000000..d0c9d150 --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift @@ -0,0 +1,338 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 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 Foundation +import JavaNet +import JavaUtilJar +import Subprocess +@_spi(Testing) import SwiftJava +import SwiftJavaConfigurationShared +import SwiftJavaShared +import SwiftJavaToolLib +import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43 + +final class AnnotationsWrapJavaTests: XCTestCase { + + // ==== ------------------------------------------------ + // MARK: @Deprecated + + func testWrapJava_deprecatedMethod() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + class DeprecatedExample { + @Deprecated + public void oldMethod() {} + public void newMethod() {} + } + """ + ) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.DeprecatedExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + @available(*, deprecated) + @JavaMethod + open func oldMethod() + """, + """ + @JavaMethod + open func newMethod() + """, + ] + ) + } + + func testWrapJava_deprecatedClass() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + @Deprecated + class OldClass { + public void doSomething() {} + } + """ + ) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.OldClass" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + @available(*, deprecated) + @JavaClass("com.example.OldClass") + open class OldClass: JavaObject { + """ + ] + ) + } + + func testWrapJava_deprecatedField() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + class FieldExample { + @Deprecated + public static int OLD_VALUE = 42; + public static int NEW_VALUE = 99; + } + """ + ) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.FieldExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + @available(*, deprecated) + @JavaStaticField(isFinal: false) + public var OLD_VALUE: Int32 + """, + """ + @JavaStaticField(isFinal: false) + public var NEW_VALUE: Int32 + """, + ] + ) + } + + func testWrapJava_deprecatedConstructor() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + class ConstructorExample { + @Deprecated + public ConstructorExample() {} + public ConstructorExample(int value) {} + } + """ + ) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.ConstructorExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + @available(*, deprecated) + @JavaMethod + @_nonoverride public convenience init(environment: JNIEnvironment? = nil) + """ + ] + ) + } + + // ==== ------------------------------------------------ + // MARK: @RequiresApi + + func testWrapJava_requiresApiMethod() async throws { + let classpathURL = try await compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + """ + package androidx.annotation; + import java.lang.annotation.*; + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) + public @interface RequiresApi { + int api() default 1; + int value() default 1; + } + """, + "com/example/ApiLevelExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + class ApiLevelExample { + @RequiresApi(api = 30) + public void api30Method() {} + public void anyApiMethod() {} + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.ApiLevelExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 30 /* Android 11 */, *) + #endif + @JavaMethod + open func api30Method() + """, + """ + @JavaMethod + open func anyApiMethod() + """, + ] + ) + } + + // ==== ------------------------------------------------ + // MARK: @Deprecated + @RequiresApi + + func testWrapJava_deprecatedAndRequiresApi() async throws { + let classpathURL = try await compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + """ + package androidx.annotation; + import java.lang.annotation.*; + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) + public @interface RequiresApi { + int api() default 1; + int value() default 1; + } + """, + "com/example/BothAnnotations.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + class BothAnnotations { + @Deprecated + @RequiresApi(api = 28) + public void oldApi28Method() {} + public void normalMethod() {} + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.BothAnnotations" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + @available(*, deprecated) + #if compiler(>=6.3) + @available(Android 28 /* Pie */, *) + #endif + @JavaMethod + open func oldApi28Method() + """, + """ + @JavaMethod + open func normalMethod() + """, + ] + ) + } + + func testWrapJava_requiresApiOnClass() async throws { + let classpathURL = try await compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + """ + package androidx.annotation; + import java.lang.annotation.*; + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) + public @interface RequiresApi { + int api() default 1; + int value() default 1; + } + """, + "com/example/TiramisuClass.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + @RequiresApi(api = 33) + class TiramisuClass { + public void doSomething() {} + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.TiramisuClass" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 33 /* Tiramisu */, *) + #endif + @JavaClass("com.example.TiramisuClass") + open class TiramisuClass: JavaObject { + """ + ] + ) + } +} + +// MARK: - Multi-file Java compilation helper + +/// Compiles multiple Java source files together, supporting different packages. +/// +/// - Parameter sourceFiles: A dictionary mapping relative file paths +/// (e.g. `"androidx/annotation/RequiresApi.java"`) to their source text. +/// - Returns: The directory that should be added to the classpath. +private func compileJavaMultiFile(_ sourceFiles: [String: String]) async throws -> Foundation.URL { + let baseDir = FileManager.default.temporaryDirectory + .appendingPathComponent("swift-java-testing-\(UUID().uuidString)") + let srcDir = baseDir.appendingPathComponent("src") + let classesDir = baseDir.appendingPathComponent("classes") + + try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: classesDir, withIntermediateDirectories: true) + + var filePaths: [String] = [] + for (relativePath, source) in sourceFiles { + let fileURL = srcDir.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try source.write(to: fileURL, atomically: true, encoding: .utf8) + filePaths.append(fileURL.path) + } + + var javacArguments: [String] = ["-d", classesDir.path] + javacArguments.append(contentsOf: filePaths) + + let javacProcess = try await Subprocess.run( + .path(.init("\(javaHome)" + "/bin/javac")), + arguments: .init(javacArguments), + output: .string(limit: Int.max, encoding: UTF8.self), + error: .string(limit: Int.max, encoding: UTF8.self) + ) + + guard javacProcess.terminationStatus.isSuccess else { + let outString = javacProcess.standardOutput ?? "" + let errString = javacProcess.standardError ?? "" + fatalError( + "javac failed (\(javacProcess.terminationStatus));\nOUT: \(outString)\nERROR: \(errString)" + ) + } + + print("Compiled java sources to: \(classesDir)") + return classesDir +}