diff --git a/Sources/JavaStdlib/JavaIO/swift-java.config b/Sources/JavaStdlib/JavaIO/swift-java.config index 1b8c152f..671e323d 100644 --- a/Sources/JavaStdlib/JavaIO/swift-java.config +++ b/Sources/JavaStdlib/JavaIO/swift-java.config @@ -1,6 +1,5 @@ { "classes" : { - "java.io.InputStream" : "InputStream", "java.io.BufferedInputStream" : "BufferedInputStream", "java.io.InputStreamReader" : "InputStreamReader", diff --git a/Sources/JavaStdlib/JavaIO/generated/InputStream.swift b/Sources/SwiftJava/generated/InputStream.swift similarity index 100% rename from Sources/JavaStdlib/JavaIO/generated/InputStream.swift rename to Sources/SwiftJava/generated/InputStream.swift diff --git a/Sources/SwiftJava/generated/JavaClass.swift b/Sources/SwiftJava/generated/JavaClass.swift index 7497e2a5..be475227 100644 --- a/Sources/SwiftJava/generated/JavaClass.swift +++ b/Sources/SwiftJava/generated/JavaClass.swift @@ -137,6 +137,9 @@ open class JavaClass: JavaObject { @JavaMethod open func getNestMembers() -> [JavaClass?] + + @JavaMethod + open func getResourceAsStream(_ arg0: String) -> InputStream! } extension JavaClass { @JavaStaticMethod diff --git a/Sources/SwiftJava/generated/JavaClassLoader.swift b/Sources/SwiftJava/generated/JavaClassLoader.swift index 827bb127..b0eaea18 100644 --- a/Sources/SwiftJava/generated/JavaClassLoader.swift +++ b/Sources/SwiftJava/generated/JavaClassLoader.swift @@ -59,6 +59,12 @@ open class JavaClassLoader: JavaObject { @JavaMethod open func clearAssertionStatus() + + @JavaMethod + open func getResourceAsStream(_ arg0: String) -> InputStream! + + @JavaMethod + open func getResource(_ arg0: String) -> JavaObject! } extension JavaClass { @JavaStaticMethod diff --git a/Sources/SwiftJava/swift-java.config b/Sources/SwiftJava/swift-java.config index fcd8a6c4..42610164 100644 --- a/Sources/SwiftJava/swift-java.config +++ b/Sources/SwiftJava/swift-java.config @@ -47,7 +47,8 @@ "java.io.OutputStream": "OutputStream", "java.io.Writer": "Writer", "java.io.PrintWriter": "PrintWriter", - "java.io.StringWriter": "StringWriter" + "java.io.StringWriter": "StringWriter", + "java.io.InputStream": "InputStream" } } diff --git a/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift b/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift new file mode 100644 index 00000000..957e5071 --- /dev/null +++ b/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift @@ -0,0 +1,331 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Minimal parser for JVM `.class` files (JVM Spec §4). +/// +/// We only handle the bare minimum subset that swift-java is interested in, and skip everything else. +struct JavaClassFileReader { + private var bytes: [UInt8] + private var offset: Int = 0 + + /// Utf8 constant pool entries, indexed by CP index (1-based). + private var utf8Constants: [Int: String] = [:] + /// Integer constant pool entries, indexed by CP index (1-based). + private var integerConstants: [Int: Int32] = [:] + + /// The parsed RuntimeInvisibleAnnotations from the class file. + private(set) var runtimeInvisibleAnnotations = JavaRuntimeInvisibleAnnotations() +} + +extension JavaClassFileReader { + + /// Parse a `.class` file and return the invisible annotations found. + static func parseRuntimeInvisibleAnnotations(_ bytes: [UInt8]) -> JavaRuntimeInvisibleAnnotations { + var reader = JavaClassFileReader(bytes: bytes) + reader.parseClassFile() + return reader.runtimeInvisibleAnnotations + } +} + +// ===== ---------------------------------------------------------------------- +// MARK: Low-level reading/skipping + +extension JavaClassFileReader { + private mutating func readU1() -> UInt8 { + let value = bytes[offset] + offset += 1 + return value + } + + private mutating func readU2() -> UInt16 { + let hi = UInt16(readU1()) + let lo = UInt16(readU1()) + return (hi << 8) | lo + } + + private mutating func readU4() -> UInt32 { + let hi = UInt32(readU2()) + let lo = UInt32(readU2()) + return (hi << 16) | lo + } + + private mutating func skip(_ count: Int) { + offset += count + } +} + +// ===== ---------------------------------------------------------------------- +// MARK: Parsing entities + +extension JavaClassFileReader { + + /// Parse the class file version numbers (§4.1). + /// Returns `(major, minor)` version. + private mutating func parseClassFileVersion() -> (major: UInt16, minor: UInt16) { + let minor = readU2() + let major = readU2() + return (major, minor) + } + + private mutating func parseClassFile() { + // Magic number + let magic = readU4() + guard magic == 0xCAFE_BABE else { return } + + // Version + _ = parseClassFileVersion() + + // Constant pool + let cpCount = Int(readU2()) + parseConstantPool(count: cpCount) + + // Access flags, this_class, super_class + _ = readU2() // access_flags + _ = readU2() // this_class + _ = readU2() // super_class + + // Interfaces + let interfacesCount = Int(readU2()) + skip(interfacesCount * 2) + + // Fields + let fieldsCount = Int(readU2()) + for _ in 0.. (String, [JavaRuntimeInvisibleAnnotation]) { + _ = readU2() // access_flags + let nameIndex = Int(readU2()) + let descriptorIndex = Int(readU2()) + let name = utf8Constants[nameIndex] ?? "" + let key: String + if includeDescriptor { + let descriptor = utf8Constants[descriptorIndex] ?? "" + key = "\(name):\(descriptor)" + } else { + key = name + } + let annotations = parseAttributes() + return (key, annotations) + } + + /// Parse an attributes table and return any annotations found in + /// `RuntimeInvisibleAnnotations` attributes. + private mutating func parseAttributes() -> [JavaRuntimeInvisibleAnnotation] { + let attributesCount = Int(readU2()) + var collected: [JavaRuntimeInvisibleAnnotation] = [] + + for _ in 0.. [JavaRuntimeInvisibleAnnotation] { + let numAnnotations = Int(readU2()) + var annotations: [JavaRuntimeInvisibleAnnotation] = [] + for _ in 0.. JavaRuntimeInvisibleAnnotation { + let typeIndex = Int(readU2()) + let typeDescriptor = utf8Constants[typeIndex] ?? "" + let numPairs = Int(readU2()) + var elements: [String: Int32] = [:] + + for _ in 0.. Int32? { + guard let tag = ElementValueTag(rawValue: readU1()) else { + return nil + } + switch tag { + case .byte, .char, .double, .float, + .long, .short, .boolean, .string: + _ = readU2() // const_value_index + return nil + + case .int: + let constIndex = Int(readU2()) + return integerConstants[constIndex] + + case .enumConstant: + _ = readU2() // type_name_index + _ = readU2() // const_name_index + return nil + + case .classInfo: + _ = readU2() // class_info_index + return nil + + case .annotation: + _ = parseAnnotation() + return nil + + case .array: + let numValues = Int(readU2()) + for _ in 0.. value. Only integer values are captured. + let elements: [String: Int32] + + /// The fully-qualified Java class name derived from `typeDescriptor`, + /// e.g. "androidx.annotation.RequiresApi". + var fullyQualifiedName: String { + // Strip leading 'L' and trailing ';', then replace '/' with '.' + var name = typeDescriptor + if name.hasPrefix("L") { name = String(name.dropFirst()) } + if name.hasSuffix(";") { name = String(name.dropLast()) } + return name.replacing("/", with: ".") + } +} diff --git a/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift b/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift new file mode 100644 index 00000000..778a7aba --- /dev/null +++ b/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// Results of scanning a .class file for RuntimeInvisibleAnnotations. +struct JavaRuntimeInvisibleAnnotations { + /// Annotations on the class itself. + var classAnnotations: [JavaRuntimeInvisibleAnnotation] = [] + + /// Annotations keyed by method name + descriptor, e.g. "api30Method:()V" + var methodAnnotations: [String: [JavaRuntimeInvisibleAnnotation]] = [:] + + /// Annotations keyed by field name, e.g. "OLD_VALUE" + var fieldAnnotations: [String: [JavaRuntimeInvisibleAnnotation]] = [:] + + /// Returns annotations for a Java method, matched by name and exact descriptor. + func annotationsFor(method javaMethod: Method) -> [JavaRuntimeInvisibleAnnotation] { + let descriptor = Self.jvmDescriptor( + parameterTypes: javaMethod.getParameterTypes(), + returnType: javaMethod.getReturnType() + ) + let key = "\(javaMethod.getName()):\(descriptor)" + return methodAnnotations[key] ?? [] + } + + /// Returns annotations for a Java constructor, matched by exact descriptor. + func annotationsFor(constructor: some Executable) -> [JavaRuntimeInvisibleAnnotation] { + let descriptor = Self.jvmDescriptor( + parameterTypes: constructor.getParameterTypes(), + returnType: nil // constructors return void + ) + let key = ":\(descriptor)" + return methodAnnotations[key] ?? [] + } + + /// Returns all annotations for a field with the given name. + func annotationsFor(field name: String) -> [JavaRuntimeInvisibleAnnotation] { + fieldAnnotations[name] ?? [] + } + + /// Build a JVM method descriptor from parameter types and return type. + /// E.g. `(Ljava/lang/String;)V` for `void doSomething(String)`. + private static func jvmDescriptor( + parameterTypes: [JavaClass?], + returnType: JavaClass? + ) -> String { + let params = parameterTypes.map { $0?.descriptorString() ?? "Ljava/lang/Object;" }.joined() + let ret = returnType?.descriptorString() ?? "V" + return "(\(params))\(ret)" + } +} diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index a30a4f51..1d3c1a59 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -59,9 +59,14 @@ struct JavaClassTranslator { /// The Swift names of the interfaces that this class implements. let swiftInterfaces: [String] - /// The annotations of the Java class + /// The annotations of the Java class. + /// In other words, RUNTIME retained annotations, visible through reflection. let annotations: [Annotation] + /// Annotations parsed from the .class file's RuntimeInvisibleAnnotations attribute. + /// These are CLASS retained annotations, not visible through reflection. + let runtimeInvisibleAnnotations: JavaRuntimeInvisibleAnnotations + /// The (instance) fields of the Java class. var fields: [Field] = [] @@ -192,6 +197,27 @@ struct JavaClassTranslator { self.annotations = javaClass.getAnnotations().compactMap(\.self) + // Parse RuntimeInvisibleAnnotations (CLASS-retention) from .class bytes. + let resourcePath = fullName.replacing(".", with: "/") + ".class" // must not have leading `/` + if let inputStream = javaClass.getClassLoader()?.getResourceAsStream(resourcePath) { + do { + let bytes = try inputStream.readAllBytes() + self.runtimeInvisibleAnnotations = JavaClassFileReader.parseRuntimeInvisibleAnnotations( + bytes.map { UInt8(bitPattern: $0) } + ) + let classCount = self.runtimeInvisibleAnnotations.classAnnotations.count + let methodCount = self.runtimeInvisibleAnnotations.methodAnnotations.count + let fieldCount = self.runtimeInvisibleAnnotations.fieldAnnotations.count + translator.log.info("Parsed runtime invisible annotations for '\(fullName)': \(classCount) class, \(methodCount) method, \(fieldCount) field") + } catch { + translator.log.warning("Failed to read .class bytes for '\(fullName)': \(error)") + self.runtimeInvisibleAnnotations = JavaRuntimeInvisibleAnnotations() + } + } else { + translator.log.warning("Could not get resource stream for '\(resourcePath)'") + self.runtimeInvisibleAnnotations = JavaRuntimeInvisibleAnnotations() + } + // Collect all of the class members that we will need to translate. // TODO: Switch over to "declared" versions of these whenever we don't need // to see inherited members. @@ -441,7 +467,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) + let classAvailableAttributes = swiftAvailableAttributes( + from: annotations, + runtimeInvisibleAnnotations: self.runtimeInvisibleAnnotations.classAnnotations + ) var classDecl: DeclSyntax = """ \(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr)) @@ -624,46 +653,46 @@ extension JavaClassTranslator { } /// Build Swift `@available` attributes from Java annotations on a reflective element. - private func swiftAvailableAttributes(from annotations: [Annotation]) -> SwiftAvailableAttributes { + private func swiftAvailableAttributes( + from runtimeAnnotations: [Annotation], + runtimeInvisibleAnnotations: [JavaRuntimeInvisibleAnnotation] = [] + ) -> SwiftAvailableAttributes { var result = SwiftAvailableAttributes() - for annotation in annotations { + func apiLevelComment(_ level: Int32) -> String { + AndroidAPILevel(rawValue: Int(level)).map { " /* \($0.name) */" } ?? "" + } + + for annotation in runtimeAnnotations { 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) */" } ?? "" - } + // Look for any annotations stored in classfiles, e.g. the Android @RequiresApi + for binAnnotation in runtimeInvisibleAnnotations { + let fqn = binAnnotation.fullyQualifiedName + if fqn == KnownJavaAnnotation.androidxRequiresApi.rawValue + || fqn == KnownJavaAnnotation.androidSupportRequiresApi.rawValue + { + let apiLevel: Int32? = + if let api = binAnnotation.elements["api"], api > 1 { + api + } else if let value = binAnnotation.elements["value"], value > 1 { + value + } else { + nil + } - // 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 { + if let apiLevel { 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 } } } @@ -707,7 +736,11 @@ extension JavaClassTranslator { let convenienceModifier = translateAsClass ? "convenience " : "" let nonoverrideAttribute = translateAsClass ? "@_nonoverride " : "" let constructorAnnotations = javaConstructor.getDeclaredAnnotations().compactMap(\.self) - let availableAttributes = swiftAvailableAttributes(from: constructorAnnotations) + let invisibleCtorAnnotations = runtimeInvisibleAnnotations.annotationsFor(constructor: javaConstructor) + let availableAttributes = swiftAvailableAttributes( + from: constructorAnnotations, + runtimeInvisibleAnnotations: invisibleCtorAnnotations + ) // FIXME: handle generics in constructors return """ @@ -832,7 +865,11 @@ extension JavaClassTranslator { // --- Handle @available attributes from Java annotations (e.g. @Deprecated, @RequiresApi) let methodAnnotations = javaMethod.getDeclaredAnnotations().compactMap(\.self) - let availableAttributes = swiftAvailableAttributes(from: methodAnnotations) + let invisibleMethodAnnotations = runtimeInvisibleAnnotations.annotationsFor(method: javaMethod) + let availableAttributes = swiftAvailableAttributes( + from: methodAnnotations, + runtimeInvisibleAnnotations: invisibleMethodAnnotations + ) // Compute the parameters for '@...JavaMethod(...)' let methodAttribute: AttributeSyntax @@ -930,7 +967,11 @@ 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) + let invisibleFieldAnnotations = runtimeInvisibleAnnotations.annotationsFor(field: javaField.getName()) + let availableAttributes = swiftAvailableAttributes( + from: fieldAnnotations, + runtimeInvisibleAnnotations: invisibleFieldAnnotations + ) if let optionalType = typeName.optionalWrappedType() { let setter = diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift b/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift new file mode 100644 index 00000000..4c9ca102 --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import Subprocess + +@testable import SwiftJavaToolLib + +/// Utility for compiling Java source files using javac in tests. +struct CompileJavaTool { + + /// 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 containing compiled `.class` files (the classpath root). + static 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 + } + + /// Compiles a single Java source file. + /// + /// - Parameter sourceText: The Java source code. + /// - Returns: The directory containing compiled `.class` files (the classpath root). + static func compileJava(_ sourceText: String) async throws -> Foundation.URL { + let sourceFile = try TempFile.create(suffix: "java", sourceText) + + let classesDir = FileManager.default.temporaryDirectory + .appendingPathComponent("swift-java-testing-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: classesDir, withIntermediateDirectories: true) + + let javacProcess = try await Subprocess.run( + .path(.init("\(javaHome)" + "/bin/javac")), + arguments: [ + "-d", classesDir.path, + sourceFile.path, + ], + 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 '\(sourceFile)' failed (\(javacProcess.terminationStatus));\nOUT: \(outString)\nERROR: \(errString)" + ) + } + + print("Compiled java sources to: \(classesDir)") + return classesDir + } + + /// Packages a classes directory into a JAR file. + /// + /// - Parameter classesDir: The directory containing compiled `.class` files. + /// - Returns: The URL of the created JAR file. + static func makeJar(classesDir: Foundation.URL) async throws -> Foundation.URL { + let jarFile = classesDir.deletingLastPathComponent() + .appendingPathComponent("test-\(UUID().uuidString).jar") + + let jarProcess = try await Subprocess.run( + .path(.init("\(javaHome)" + "/bin/jar")), + arguments: ["cf", jarFile.path, "-C", classesDir.path, "."], + output: .string(limit: Int.max, encoding: UTF8.self), + error: .string(limit: Int.max, encoding: UTF8.self) + ) + + guard jarProcess.terminationStatus.isSuccess else { + let outString = jarProcess.standardOutput ?? "" + let errString = jarProcess.standardError ?? "" + fatalError( + "jar failed (\(jarProcess.terminationStatus));\nOUT: \(outString)\nERROR: \(errString)" + ) + } + + print("Created JAR: \(jarFile)") + return jarFile + } +} diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift index e2fad706..f1dbc4ee 100644 --- a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift +++ b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift @@ -22,43 +22,9 @@ import SwiftJavaShared import SwiftJavaToolLib import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43 -private func createTemporaryDirectory(in directory: Foundation.URL) throws -> Foundation.URL { - let uuid = UUID().uuidString - let resolverDirectoryURL = directory.appendingPathComponent("swift-java-testing-\(uuid)") - - try FileManager.default.createDirectory(at: resolverDirectoryURL, withIntermediateDirectories: true, attributes: nil) - - return resolverDirectoryURL -} - /// Returns the directory that should be added to the classpath of the JVM to analyze the sources. func compileJava(_ sourceText: String) async throws -> Foundation.URL { - let sourceFile = try TempFile.create(suffix: "java", sourceText) - - let classesDirectory = try createTemporaryDirectory(in: FileManager.default.temporaryDirectory) - - let javacProcess = try await Subprocess.run( - .path(.init("\(javaHome)" + "/bin/javac")), - arguments: [ - "-d", classesDirectory.path, // output directory for .class files - sourceFile.path, - ], - output: .string(limit: Int.max, encoding: UTF8.self), - error: .string(limit: Int.max, encoding: UTF8.self) - ) - - // Check if compilation was successful - guard javacProcess.terminationStatus.isSuccess else { - let outString = javacProcess.standardOutput ?? "" - let errString = javacProcess.standardError ?? "" - fatalError( - "javac '\(sourceFile)' failed (\(javacProcess.terminationStatus));\n" + "OUT: \(outString)\n" - + "ERROR: \(errString)" - ) - } - - print("Compiled java sources to: \(classesDirectory)") - return classesDirectory + try await CompileJavaTool.compileJava(sourceText) } func withJavaTranslator( @@ -93,7 +59,9 @@ func withJavaTranslator( /// each of the expected "chunks" of text. func assertWrapJavaOutput( javaClassNames: [String], + classNameMappings: [String: String] = [:], classpath: [Foundation.URL], + makeJar: Bool = false, assert assertBody: (JavaTranslator) throws -> Void = { _ in }, expectedChunks: [String], function: String = #function, @@ -118,7 +86,27 @@ func assertWrapJavaOutput( translateAsClass: true ) - let classpathJavaURLs = classpath.map({ try! URL.init("\($0)/") }) // we MUST have a trailing slash for JVM to consider it a search directory + let classpathJavaURLs: [JavaNet.URL] + if makeJar { + // Convert each classpath directory into a JAR and use those as classpath entries + classpathJavaURLs = classpath.map { classpathDir in + let jarFile = classpathDir.deletingLastPathComponent() + .appendingPathComponent("test-\(UUID().uuidString).jar") + // Synchronously create the JAR using Process + let process = Process() + process.executableURL = Foundation.URL(fileURLWithPath: "\(javaHome)/bin/jar") + process.arguments = ["cf", jarFile.path, "-C", classpathDir.path, "."] + try! process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + fatalError("jar failed with exit code \(process.terminationStatus)") + } + print("Created JAR: \(jarFile)") + return try! JavaNet.URL.init("\(jarFile)") + } + } else { + classpathJavaURLs = classpath.map({ try! JavaNet.URL.init("\($0)/") }) // we MUST have a trailing slash for JVM to consider it a search directory + } let classLoader = URLClassLoader(classpathJavaURLs, environment: environment) // FIXME: deduplicate this @@ -137,8 +125,9 @@ func assertWrapJavaOutput( // TODO: especially because nested classes // WrapJavaCommand(). - let swiftUnqualifiedName = javaClassName.javaClassNameToCanonicalName - .defaultSwiftNameForJavaClass + let swiftUnqualifiedName = + classNameMappings[javaClassName] + ?? javaClassName.javaClassNameToCanonicalName.defaultSwiftNameForJavaClass translator.translatedClasses[javaClassName] = .init(module: nil, name: swiftUnqualifiedName) diff --git a/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift b/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift new file mode 100644 index 00000000..3d85f453 --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift @@ -0,0 +1,201 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import XCTest + +@testable import SwiftJavaToolLib + +final class JavaClassFileParserTests: XCTestCase { + + /// Fake `@RequiresApi` annotation with CLASS retention, matching real AndroidX behavior. + static let RequiresApi_ClassRetention = """ + package androidx.annotation; + import java.lang.annotation.*; + @Retention(RetentionPolicy.CLASS) + @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) + public @interface RequiresApi { + int api() default 1; + int value() default 1; + } + """ + + func test_parseClassAnnotation() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/MyClass.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + @RequiresApi(api = 33) + public class MyClass { + public void hello() {} + } + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/MyClass.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertEqual(result.classAnnotations.count, 1) + let annotation = result.classAnnotations[0] + XCTAssertEqual(annotation.fullyQualifiedName, "androidx.annotation.RequiresApi") + XCTAssertEqual(annotation.elements["api"], 33) + } + + func test_parseMethodAnnotation() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/MethodExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + public class MethodExample { + @RequiresApi(api = 30) + public void api30Method() {} + public void normalMethod() {} + } + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/MethodExample.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertTrue(result.classAnnotations.isEmpty) + + // Find the annotated method by key prefix + let api30Annotations = result.methodAnnotations.filter { $0.key.hasPrefix("api30Method:") } + XCTAssertEqual(api30Annotations.count, 1) + let annotations = api30Annotations.values.first! + XCTAssertEqual(annotations.count, 1) + XCTAssertEqual(annotations[0].fullyQualifiedName, "androidx.annotation.RequiresApi") + XCTAssertEqual(annotations[0].elements["api"], 30) + + // normalMethod should have no annotations + let normalAnnotations = result.methodAnnotations.filter { $0.key.hasPrefix("normalMethod:") } + XCTAssertTrue(normalAnnotations.isEmpty) + } + + func test_parseFieldAnnotation() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/FieldExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + public class FieldExample { + @RequiresApi(api = 28) + public static int API_FIELD = 42; + public static int NORMAL_FIELD = 99; + } + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/FieldExample.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertEqual(result.fieldAnnotations["API_FIELD"]?.count, 1) + XCTAssertEqual(result.fieldAnnotations["API_FIELD"]?[0].fullyQualifiedName, "androidx.annotation.RequiresApi") + XCTAssertEqual(result.fieldAnnotations["API_FIELD"]?[0].elements["api"], 28) + XCTAssertNil(result.fieldAnnotations["NORMAL_FIELD"]) + } + + func test_parseConstructorAnnotation() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/CtorExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + public class CtorExample { + @RequiresApi(api = 31) + public CtorExample() {} + } + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/CtorExample.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + let ctorAnnotations = result.methodAnnotations.filter { $0.key.hasPrefix(":") } + XCTAssertEqual(ctorAnnotations.count, 1) + let annotations = ctorAnnotations.values.first! + XCTAssertEqual(annotations.count, 1) + XCTAssertEqual(annotations[0].fullyQualifiedName, "androidx.annotation.RequiresApi") + XCTAssertEqual(annotations[0].elements["api"], 31) + } + + func test_parseValueElement() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/ValueExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + @RequiresApi(value = 26) + public class ValueExample {} + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/ValueExample.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertEqual(result.classAnnotations.count, 1) + XCTAssertEqual(result.classAnnotations[0].elements["value"], 26) + // "api" should not be present (or default to 1, but javac omits defaults) + XCTAssertNil(result.classAnnotations[0].elements["api"]) + } + + func test_noRuntimeAnnotations() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "com/example/PlainClass.java": + """ + package com.example; + public class PlainClass { + @Deprecated + public void oldMethod() {} + public void newMethod() {} + } + """ + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/PlainClass.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + // @Deprecated has RUNTIME retention, so it won't appear in RuntimeInvisibleAnnotations + XCTAssertTrue(result.classAnnotations.isEmpty) + XCTAssertTrue(result.methodAnnotations.isEmpty) + XCTAssertTrue(result.fieldAnnotations.isEmpty) + } + + func test_invalidMagic() { + let bytes: [UInt8] = [0x00, 0x12, 0x34, 0x00] + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertTrue(result.classAnnotations.isEmpty) + XCTAssertTrue(result.methodAnnotations.isEmpty) + XCTAssertTrue(result.fieldAnnotations.isEmpty) + } +} diff --git a/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift b/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift index d0c9d150..5c51d59e 100644 --- a/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift +++ b/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift @@ -24,6 +24,19 @@ import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/is final class AnnotationsWrapJavaTests: XCTestCase { + /// Java source for a CLASS-retention `@RequiresApi` annotation, + /// matching real AndroidX behavior. + static let requiresApiAnnotationSource = """ + package androidx.annotation; + import java.lang.annotation.*; + @Retention(RetentionPolicy.CLASS) + @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) + public @interface RequiresApi { + int api() default 1; + int value() default 1; + } + """ + // ==== ------------------------------------------------ // MARK: @Deprecated @@ -150,18 +163,44 @@ final class AnnotationsWrapJavaTests: XCTestCase { // 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; + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "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() + """, + ] + ) + } + + func testWrapJava_requiresApiMethod_fromJar() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, "com/example/ApiLevelExample.java": """ package com.example; @@ -179,6 +218,7 @@ final class AnnotationsWrapJavaTests: XCTestCase { "com.example.ApiLevelExample" ], classpath: [classpathURL], + makeJar: true, expectedChunks: [ """ #if compiler(>=6.3) @@ -195,22 +235,79 @@ final class AnnotationsWrapJavaTests: XCTestCase { ) } + func testWrapJava_requiresApiField() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/ApiFieldExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + class ApiFieldExample { + @RequiresApi(api = 30) + public static int API30_FIELD = 42; + public static int NORMAL_FIELD = 99; + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.ApiFieldExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 30 /* Android 11 */, *) + #endif + @JavaStaticField(isFinal: false) + public var API30_FIELD: Int32 + """, + """ + @JavaStaticField(isFinal: false) + public var NORMAL_FIELD: Int32 + """, + ] + ) + } + + func testWrapJava_requiresApiConstructor() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/ApiConstructorExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + class ApiConstructorExample { + @RequiresApi(api = 33) + public ApiConstructorExample() {} + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.ApiConstructorExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 33 /* Tiramisu */, *) + #endif + @JavaMethod + @_nonoverride public convenience init(environment: JNIEnvironment? = nil) + """ + ] + ) + } + // ==== ------------------------------------------------ // 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; - } - """, + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, "com/example/BothAnnotations.java": """ package com.example; @@ -247,18 +344,8 @@ final class AnnotationsWrapJavaTests: XCTestCase { } 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; - } - """, + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, "com/example/TiramisuClass.java": """ package com.example; @@ -286,53 +373,108 @@ final class AnnotationsWrapJavaTests: XCTestCase { ] ) } -} -// 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 + func testWrapJava_requiresApiSameSimpleNameDifferentPackages() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/Example.java": + """ + package com.example; + + public class Example { + @androidx.annotation.RequiresApi(api = 28) + public void doWork() {} + } + """, + "com/another/Example.java": + """ + package com.another; + public class Example { + @androidx.annotation.RequiresApi(api = 33) + public void doWork() {} + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.Example", + "com.another.Example", + ], + classNameMappings: [ + "com.example.Example": "ExampleOne", + "com.another.Example": "ExampleTwo", + ], + classpath: [classpathURL], + expectedChunks: [ + // com.example.Example — API 28 + """ + @JavaClass("com.example.Example") + open class ExampleOne: JavaObject { + """, + """ + #if compiler(>=6.3) + @available(Android 28 /* Pie */, *) + #endif + @JavaMethod + open func doWork() + """, + // com.another.Example — API 33 + """ + @JavaClass("com.another.Example") + open class ExampleTwo: JavaObject { + """, + """ + #if compiler(>=6.3) + @available(Android 33 /* Tiramisu */, *) + #endif + @JavaMethod + open func doWork() + """, + ] ) - 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)" + func testWrapJava_requiresApiOnSpecificMethod() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/TiramisuClass.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + + class TiramisuClass { + @RequiresApi(api = 11) + public void doSomething(String name) {} + + @RequiresApi(api = 33) + public void doSomething() {} + } + """, + ]) + + // Only the specific overload gets the annotation + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.TiramisuClass" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 11 /* Honeycomb */, *) + #endif + @JavaMethod + open func doSomething(_ arg0: String) + """, + """ + #if compiler(>=6.3) + @available(Android 33 /* Tiramisu */, *) + #endif + @JavaMethod + open func doSomething() + """, + ] ) } - - print("Compiled java sources to: \(classesDir)") - return classesDir }