diff --git a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift index 5b4a89c3..e370bc76 100644 --- a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift +++ b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift @@ -139,7 +139,11 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { arguments += [ "--generated-java-sources-list-file-output", javaSourcesListFileName, ] - jextractOutputFiles += [javaSourcesFile] + // NOTE: javaSourcesFile is intentionally NOT added to jextractOutputFiles. + // Adding a non-Swift file as a build command output causes SPM to bundle it + // as a module resource, which triggers resource_bundle_accessor.swift generation + // and pulls in Foundation.Bundle. The file is still written by the tool as a + // side-effect; the java-callbacks-build command reads it by its known path. } commands += [ @@ -148,7 +152,7 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { executable: toolURL, arguments: arguments, inputFiles: [configFile] + swiftFiles, - outputFiles: jextractOutputFiles + outputFiles: jextractOutputFiles, ) ] @@ -188,85 +192,48 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { ?? swiftJavaDirectory.appending(path: "gradlew") // fallback to calling ./gradlew if gradle is not installed log("Detected 'gradle' executable (or gradlew fallback): \(gradleExecutable)") - commands += [ - .buildCommand( - displayName: "Build SwiftKitCore using Gradle (Java)", - executable: gradleExecutable, - arguments: [ - ":SwiftKitCore:build", - "--project-dir", swiftJavaDirectory.path(percentEncoded: false), - "--gradle-user-home", gradleUserHomePath, - "--configure-on-demand", - "--no-daemon", - ], - environment: gradlewEnvironment, - inputFiles: [swiftJavaDirectory], - outputFiles: [swiftKitCoreClassPath] - ) - ] - - // Compile the jextracted sources let javaHome = URL(filePath: findJavaHome()) - - commands += [ - .buildCommand( - displayName: "Build extracted Java sources", - executable: - javaHome - .appending(path: "bin") - .appending(path: self.javacName), - arguments: [ - "@\(javaSourcesFile.path(percentEncoded: false))", - "-d", javaCompiledClassesURL.path(percentEncoded: false), - "-parameters", - "-classpath", swiftKitCoreClassPath.path(percentEncoded: false), - ], - inputFiles: [javaSourcesFile, swiftKitCoreClassPath], - outputFiles: [javaCompiledClassesURL] - ) - ] - - // Run `configure` to extract a swift-java config to use for wrap-java - let swiftJavaConfigURL = context.pluginWorkDirectoryURL.appending(path: "swift-java.config") - - commands += [ - .buildCommand( - displayName: "Output swift-java.config that contains all extracted Java sources", - executable: toolURL, - arguments: [ - "configure", - "--output-directory", context.pluginWorkDirectoryURL.path(percentEncoded: false), - "--cp", javaCompiledClassesURL.path(percentEncoded: false), - "--swift-module", sourceModule.name, - "--swift-type-prefix", "Java", - ], - inputFiles: [javaCompiledClassesURL], - outputFiles: [swiftJavaConfigURL] - ) - ] + let javacPath = + javaHome + .appending(path: "bin") + .appending(path: self.javacName) let singleSwiftFileOutputName = "WrapJavaGenerated.swift" - - // In the end we can run wrap-java on the previous inputs - var wrapJavaArguments = [ - "wrap-java", + let javaCallbacksSwiftOutput = outputSwiftDirectory.appending(path: singleSwiftFileOutputName) + + // Combine gradle + javac + configure + wrap-java into a single command that + // declares only a Swift file as its output. This avoids SPM treating any + // intermediate artifact (Gradle output directories, compiled .class files, + // swift-java.config) as module resources, which would trigger + // resource_bundle_accessor.swift generation and pull Foundation.Bundle into + // the target binary. + // + // inputFiles includes the Swift outputs from jextract so that SPM knows + // this command must run after jextract finishes. + var javaCallbacksArguments = [ + "java-callbacks-build", + "--swift-java-tool", toolURL.path(percentEncoded: false), + "--gradle-executable", gradleExecutable.path(percentEncoded: false), + "--gradle-project-dir", swiftJavaDirectory.path(percentEncoded: false), + "--gradle-user-home", gradleUserHomePath, + "--javac", javacPath.path(percentEncoded: false), + "--java-sources-list", javaSourcesFile.path(percentEncoded: false), + "--java-output-directory", javaCompiledClassesURL.path(percentEncoded: false), + "--swift-kit-core-classpath", swiftKitCoreClassPath.path(percentEncoded: false), "--swift-module", sourceModule.name, + "--swift-type-prefix", "Java", "--output-directory", outputSwiftDirectory.path(percentEncoded: false), - "--config", swiftJavaConfigURL.path(percentEncoded: false), - "--cp", swiftKitCoreClassPath.path(percentEncoded: false), "--single-swift-file-output", singleSwiftFileOutputName, ] - - // Add any dependent config files as arguments - wrapJavaArguments += dependentConfigFilesArguments + javaCallbacksArguments += dependentConfigFilesArguments commands += [ .buildCommand( - displayName: "Wrap compiled Java sources using wrap-java", + displayName: "Build SwiftKitCore, compile Java callbacks, and generate Swift wrappers", executable: toolURL, - arguments: wrapJavaArguments, - inputFiles: [swiftJavaConfigURL, swiftKitCoreClassPath], - outputFiles: [outputSwiftDirectory.appending(path: singleSwiftFileOutputName)] + arguments: javaCallbacksArguments, + inputFiles: outputSwiftFiles + [swiftJavaDirectory], + outputFiles: [javaCallbacksSwiftOutput], ) ] diff --git a/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift new file mode 100644 index 00000000..759f9875 --- /dev/null +++ b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift @@ -0,0 +1,213 @@ +//===----------------------------------------------------------------------===// +// +// 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 ArgumentParser +import Foundation +import Subprocess + +#if canImport(System) +import System +#else +@preconcurrency import SystemPackage +#endif + +extension SwiftJava { + /// Builds Swift-Java callbacks in a single command: + /// 1. Building SwiftKitCore with Gradle + /// 2. Compiling extracted Java sources with javac + /// 3. Running `swift-java configure` to produce a swift-java.config + /// 4. Running `swift-java wrap-java` to generate Swift wrappers + /// + /// **WORKAROUND**: rdar://172649681 if we invoke commands one by one with java outputs SwiftPM will link Foundation + /// + /// This command is used by ``JExtractSwiftPlugin`` to consolidate all of the above + /// into a single build command that declares only a Swift file as its output, + /// avoiding SPM treating intermediate Java artifacts (compiled classes, config files, + /// Gradle output directories) as module resources, which would trigger + /// resource_bundle_accessor.swift generation and pull Foundation.Bundle into the binary. + struct JavaCallbacksBuildCommand: AsyncParsableCommand { + + static let configuration = CommandConfiguration( + commandName: "java-callbacks-build", + abstract: + "Build SwiftKitCore, compile Java callbacks, and generate Swift wrappers (for use by JExtractSwiftPlugin)", + shouldDisplay: false, + ) + + // MARK: Gradle options + + @Option(help: "Path to the gradle (or gradlew) executable") + var gradleExecutable: String + + @Option(help: "The Gradle project directory (passed as --project-dir)") + var gradleProjectDir: String + + @Option(help: "The Gradle user home directory (GRADLE_USER_HOME)") + var gradleUserHome: String + + // MARK: javac options + + @Option(help: "Path to the javac executable") + var javac: String + + @Option(help: "Path to the @-file listing Java sources to compile") + var javaSourcesList: String + + @Option(help: "Directory where compiled Java classes should be output") + var javaOutputDirectory: String + + @Option(help: "Path to SwiftKitCore compiled classes (classpath for javac and wrap-java)") + var swiftKitCoreClasspath: String + + // MARK: Swift generation options + + @Option(help: "The name of the Swift module") + var swiftModule: String + + @Option(help: "Prefix to add to generated Swift type names") + var swiftTypePrefix: String? + + @Option( + name: .customLong("output-directory"), + help: "Directory where generated Swift files should be written", + ) + var outputDirectory: String + + @Option(help: "Name of the single Swift output file") + var singleSwiftFileOutput: String + + @Option(help: "Path to the swift-java tool executable (used to invoke subcommands)") + var swiftJavaTool: String + + @Option( + help: + "Dependent module configurations (format: ModuleName=/path/to/swift-java.config)" + ) + var dependsOn: [String] = [] + + mutating func run() async throws { + let outputDir = URL(fileURLWithPath: outputDirectory) + let outputFile = outputDir.appendingPathComponent(singleSwiftFileOutput) + + // 1. Build SwiftKitCore using Gradle. + try await runSubprocess( + executable: gradleExecutable, + arguments: [ + ":SwiftKitCore:build", + "--project-dir", gradleProjectDir, + "--gradle-user-home", gradleUserHome, + "--configure-on-demand", + "--no-daemon", + ], + environment: .inherit.updating(["GRADLE_USER_HOME": gradleUserHome]), + errorMessage: "gradle :SwiftKitCore:build", + ) + + // If the sources list does not exist, jextract produced no Java callbacks. + // Write an empty placeholder Swift file and return early. + guard FileManager.default.fileExists(atPath: javaSourcesList) else { + try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) + try "// No Java callbacks generated\n".write( + to: outputFile, + atomically: true, + encoding: .utf8, + ) + return + } + + // 2. Compile Java sources with javac. + try FileManager.default.createDirectory( + atPath: javaOutputDirectory, + withIntermediateDirectories: true, + ) + + try await runSubprocess( + executable: javac, + arguments: [ + "@\(javaSourcesList)", + "-d", javaOutputDirectory, + "-parameters", + "-classpath", swiftKitCoreClasspath, + ], + errorMessage: "javac", + ) + + // 3. Generate swift-java.config from compiled classes. + // Written into javaOutputDirectory (inside pluginWorkDirectory) but NOT + // declared as a build command output, so SPM will not bundle it as a resource. + var configureArgs = [ + "configure", + "--output-directory", javaOutputDirectory, + "--cp", javaOutputDirectory, + "--swift-module", swiftModule, + ] + if let prefix = swiftTypePrefix { + configureArgs += ["--swift-type-prefix", prefix] + } + + try await runSubprocess( + executable: swiftJavaTool, + arguments: configureArgs, + errorMessage: "swift-java configure", + ) + + // 4. Generate Swift wrappers using wrap-java. + let configPath = URL(fileURLWithPath: javaOutputDirectory) + .appendingPathComponent("swift-java.config").path + + var wrapJavaArgs = [ + "wrap-java", + "--swift-module", swiftModule, + "--output-directory", outputDirectory, + "--config", configPath, + "--cp", swiftKitCoreClasspath, + "--single-swift-file-output", singleSwiftFileOutput, + ] + wrapJavaArgs += dependsOn.flatMap { ["--depends-on", $0] } + + try await runSubprocess( + executable: swiftJavaTool, + arguments: wrapJavaArgs, + errorMessage: "swift-java wrap-java", + ) + } + } +} + +// MARK: - Helpers + +private func runSubprocess( + executable: String, + arguments: [String], + environment: Subprocess.Environment = .inherit, + errorMessage: String, +) async throws { + let result = try await Subprocess.run( + .path(FilePath(executable)), + arguments: .init(arguments), + environment: environment, + output: .standardOutput, + error: .standardError, + ) + guard result.terminationStatus.isSuccess else { + throw JavaCallbacksBuildError( + "\(errorMessage) failed with exit status \(result.terminationStatus)" + ) + } +} + +struct JavaCallbacksBuildError: Error, CustomStringConvertible { + let description: String + init(_ message: String) { self.description = message } +} diff --git a/Sources/SwiftJavaTool/SwiftJava.swift b/Sources/SwiftJavaTool/SwiftJava.swift index 65b27b28..5a514e2a 100644 --- a/Sources/SwiftJavaTool/SwiftJava.swift +++ b/Sources/SwiftJavaTool/SwiftJava.swift @@ -37,6 +37,7 @@ struct SwiftJava: AsyncParsableCommand { ResolveCommand.self, WrapJavaCommand.self, JExtractCommand.self, + JavaCallbacksBuildCommand.self, ] ) diff --git a/Tests/LinkageTest/Package.swift b/Tests/LinkageTest/Package.swift index 9d7a5ab2..34a772a6 100644 --- a/Tests/LinkageTest/Package.swift +++ b/Tests/LinkageTest/Package.swift @@ -3,164 +3,32 @@ import Foundation import PackageDescription -// Note: the JAVA_HOME environment variable must be set to point to where -// Java is installed, e.g., -// Library/Java/JavaVirtualMachines/openjdk-21.jdk/Contents/Home. -func findJavaHome() -> String { - if let home = ProcessInfo.processInfo.environment["JAVA_HOME"] { - return home - } - - // This is a workaround for envs (some IDEs) which have trouble with - // picking up env variables during the build process - let path = "\(FileManager.default.homeDirectoryForCurrentUser.path()).java_home" - if let home = try? String(contentsOfFile: path, encoding: .utf8) { - if let lastChar = home.last, lastChar.isNewline { - return String(home.dropLast()) - } - - return home - } - - if let home = getJavaHomeFromLibexecJavaHome(), - !home.isEmpty - { - return home - } - - if let home = getJavaHomeFromSDKMAN() { - return home - } - - if let home = getJavaHomeFromPath() { - return home - } - - if ProcessInfo.processInfo.environment["SPI_PROCESSING"] == "1" - && ProcessInfo.processInfo.environment["SPI_BUILD"] == nil - { - return "" - } - fatalError("Please set the JAVA_HOME environment variable to point to where Java is installed.") -} - -/// On MacOS we can use the java_home tool as a fallback if we can't find JAVA_HOME environment variable. -func getJavaHomeFromLibexecJavaHome() -> String? { - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/libexec/java_home") - - guard FileManager.default.fileExists(atPath: task.executableURL!.path) else { - return nil - } - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - - do { - try task.run() - task.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) - - if task.terminationStatus == 0 { - return output - } else { - return nil - } - } catch { - return nil - } -} - -func getJavaHomeFromSDKMAN() -> String? { - let home = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".sdkman/candidates/java/current") - - let javaBin = home.appendingPathComponent("bin/java").path - if FileManager.default.isExecutableFile(atPath: javaBin) { - return home.path - } - return nil -} - -func getJavaHomeFromPath() -> String? { - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/which") - task.arguments = ["java"] - - let pipe = Pipe() - task.standardOutput = pipe - - do { - try task.run() - task.waitUntilExit() - guard task.terminationStatus == 0 else { return nil } - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard - let javaPath = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !javaPath.isEmpty - else { return nil } - - let resolved = URL(fileURLWithPath: javaPath).resolvingSymlinksInPath() - return - resolved - .deletingLastPathComponent() - .deletingLastPathComponent() - .path - } catch { - return nil - } -} - -let javaHome = findJavaHome() - -let javaIncludePath = "\(javaHome)/include" -#if os(Linux) -let javaPlatformIncludePath = "\(javaIncludePath)/linux" -#elseif os(macOS) -let javaPlatformIncludePath = "\(javaIncludePath)/darwin" -#elseif os(Windows) -let javaPlatformIncludePath = "\(javaIncludePath)/win32" -#endif - let package = Package( name: "linkage-test", + platforms: [ + .macOS(.v15) + ], dependencies: [ .package(name: "swift-java", path: "../..") ], targets: [ .executableTarget( name: "LinkageTest", + dependencies: [ + .product(name: "SwiftJava", package: "swift-java") + ] + ), + .executableTarget( + name: "JExtractLinkageTest", dependencies: [ .product(name: "SwiftJava", package: "swift-java") ], - swiftSettings: [ - .unsafeFlags(["-I\(javaIncludePath)", "-I\(javaPlatformIncludePath)"]) + exclude: [ + "swift-java.config" ], - linkerSettings: [ - .unsafeFlags( - [ - "-L\(javaHome)/lib/server", - "-Xlinker", "-rpath", - "-Xlinker", "\(javaHome)/lib/server", - ], - .when(platforms: [.linux, .macOS]) - ), - .unsafeFlags( - [ - "-L\(javaHome)/lib" - ], - .when(platforms: [.windows]) - ), - .linkedLibrary( - "jvm", - .when(platforms: [.linux, .macOS, .windows]) - ), + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") ] - ) + ), ] ) diff --git a/Tests/LinkageTest/Sources/JExtractLinkageTest/JExtractLinkageTest.swift b/Tests/LinkageTest/Sources/JExtractLinkageTest/JExtractLinkageTest.swift new file mode 100644 index 00000000..e8ac8a15 --- /dev/null +++ b/Tests/LinkageTest/Sources/JExtractLinkageTest/JExtractLinkageTest.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024-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 final class JExtractLinkageTest { + public init() {} + + public func hello() -> String { + "Hello from JExtractLinkageTest" + } +} diff --git a/Tests/LinkageTest/Sources/JExtractLinkageTest/main.swift b/Tests/LinkageTest/Sources/JExtractLinkageTest/main.swift new file mode 100644 index 00000000..2f34f1f4 --- /dev/null +++ b/Tests/LinkageTest/Sources/JExtractLinkageTest/main.swift @@ -0,0 +1,19 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024-2025 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 SwiftJava + +let _ = try? JavaVirtualMachine.shared() +let _ = SwiftPlatform.debugOrRelease +print("Linkage test passed: SwiftJava imported successfully.") \ No newline at end of file diff --git a/Tests/LinkageTest/Sources/JExtractLinkageTest/swift-java.config b/Tests/LinkageTest/Sources/JExtractLinkageTest/swift-java.config new file mode 100644 index 00000000..82dd9e10 --- /dev/null +++ b/Tests/LinkageTest/Sources/JExtractLinkageTest/swift-java.config @@ -0,0 +1,5 @@ +{ + "javaPackage": "com.example.swift", + "mode": "jni", + "enableJavaCallbacks": true +} diff --git a/scripts/run-linkage-test.sh b/scripts/run-linkage-test.sh index 255f8386..21ae1e27 100755 --- a/scripts/run-linkage-test.sh +++ b/scripts/run-linkage-test.sh @@ -24,30 +24,37 @@ echo "Detected JAVA_HOME=${JAVA_HOME}" echo "Running on Linux - proceeding with linkage test..." -# Build the linkage test package +# Build all targets in the linkage test package in one go echo "Building linkage test package..." -swift build --package-path Tests/LinkageTest +swift build --package-path Tests/LinkageTest --disable-sandbox -# Construct build path build_path=$(swift build --package-path Tests/LinkageTest --show-bin-path) -binary_path="$build_path/LinkageTest" -# Verify the binary exists -if [[ ! -f "$binary_path" ]]; then - echo "Error: Built binary not found at $binary_path" >&2 - exit 1 -fi - -echo "Checking linkage for binary: $binary_path" - -# Run ldd and check if libFoundation.so is linked -ldd_output=$(ldd "$binary_path") -echo "LDD output:" -echo "$ldd_output" - -if echo "$ldd_output" | grep -q "libFoundation.so"; then - echo "Error: Binary is linked against libFoundation.so - this indicates incorrect linkage. Ensure the full Foundation is not linked on Linux when FoundationEssentials is available." >&2 - exit 1 -else - echo "Success: Binary is not linked against libFoundation.so - linkage test passed." -fi +check_linkage() { + local name="$1" + local binary="$build_path/$name" + + if [[ ! -f "$binary" ]]; then + echo "Error: Built binary not found at $binary" >&2 + exit 1 + fi + + echo "Checking linkage for binary: $binary" + local ldd_output + ldd_output=$(ldd "$binary") + echo "LDD output:" + echo "$ldd_output" + + if echo "$ldd_output" | grep -q "libFoundation.so"; then + echo "Error: $name is linked against libFoundation.so - this indicates incorrect linkage. Ensure the full Foundation is not linked on Linux when FoundationEssentials is available." >&2 + exit 1 + else + echo "Success: $name is not linked against libFoundation.so - linkage test passed." + fi +} + +check_linkage "LinkageTest" + +echo "" +echo "Running JExtract linkage test (JExtractSwiftPlugin with enableJavaCallbacks)..." +check_linkage "JExtractLinkageTest"