From e88fe4d11f42e979297bad5db01cfe52c8f38c26 Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Sat, 14 Mar 2026 12:28:35 +0100 Subject: [PATCH 1/5] add explicit command --- .../JExtractSwiftPlugin.swift | 104 +++------ .../Commands/JavaCallbacksBuildCommand.swift | 208 ++++++++++++++++++ Sources/SwiftJavaTool/SwiftJava.swift | 1 + 3 files changed, 244 insertions(+), 69 deletions(-) create mode 100644 Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift diff --git a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift index 5b4a89c3..926f9237 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 += [ @@ -188,85 +192,47 @@ 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..9754d4a8 --- /dev/null +++ b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift @@ -0,0 +1,208 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// 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 +/// +/// 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. +extension SwiftJava { + 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, ] ) From 1aae2326401c57c459dcedd19fa123ad3297b60a Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Sat, 14 Mar 2026 15:08:29 +0100 Subject: [PATCH 2/5] fix conditional import --- .../SwiftTypes/SwiftSymbolTable.swift | 13 +++++++------ .../JExtractSwiftTests/Asserts/TextAssertions.swift | 10 ++++++++++ .../JExtractSwiftTests/FoundationImportTests.swift | 11 +++++++---- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift b/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift index 162b67e9..e5f5cca5 100644 --- a/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift +++ b/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift @@ -176,12 +176,13 @@ extension SwiftSymbolTable { continue } - // Try to print only on main module from relation chain as it has every other module. - guard - !mainSymbolSourceModules.isDisjoint(with: alternativeModules.moduleNames) - || alternativeModules.isMainSourceOfSymbols - else { - if !alternativeModules.isMainSourceOfSymbols { + // Only the main source of symbols emits the conditional import block. + // Secondary modules (e.g. FoundationEssentials when Foundation is the main source) + // are skipped when their main source is already present, because the main source's + // block already covers the import. If no main source is present, fall back to a + // plain import so the module is still imported. + guard alternativeModules.isMainSourceOfSymbols else { + if mainSymbolSourceModules.isDisjoint(with: alternativeModules.moduleNames) { printer.print("import \(module)") } continue diff --git a/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift b/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift index 30cd617c..d559187f 100644 --- a/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift +++ b/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift @@ -33,6 +33,7 @@ func assertOutput( detectChunkByInitialLines _detectChunkByInitialLines: Int = 4, javaClassLookupTable: [String: String] = [:], expectedChunks: [String], + notExpectedChunks: [String] = [], fileID: String = #fileID, filePath: String = #filePath, line: Int = #line, @@ -83,6 +84,15 @@ func assertOutput( } output = printer.finalize() + let sourceLocation = SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + for notExpectedChunk in notExpectedChunks { + #expect( + !output.contains(notExpectedChunk), + "Output must not contain:\n\(notExpectedChunk)\n\nGot output:\n\(output)", + sourceLocation: sourceLocation + ) + } + let gotLines = output.split(separator: "\n").filter { l in l.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).count > 0 } diff --git a/Tests/JExtractSwiftTests/FoundationImportTests.swift b/Tests/JExtractSwiftTests/FoundationImportTests.swift index b7c77294..4e631142 100644 --- a/Tests/JExtractSwiftTests/FoundationImportTests.swift +++ b/Tests/JExtractSwiftTests/FoundationImportTests.swift @@ -19,7 +19,6 @@ import Testing struct FoundationImportTests { @Test("Import Foundation", arguments: [JExtractGenerationMode.jni, JExtractGenerationMode.ffm]) func import_foundation(mode: JExtractGenerationMode) throws { - try assertOutput( input: "import Foundation", mode, @@ -33,7 +32,6 @@ struct FoundationImportTests { @Test("Import FoundationEssentials", arguments: [JExtractGenerationMode.jni, JExtractGenerationMode.ffm]) func import_foundationEssentials(mode: JExtractGenerationMode) throws { - try assertOutput( input: "import FoundationEssentials", mode, @@ -47,8 +45,7 @@ struct FoundationImportTests { @Test("Import conditional foundation", arguments: [JExtractGenerationMode.jni, JExtractGenerationMode.ffm]) func import_conditionalFoundation(mode: JExtractGenerationMode) throws { - let ifConfigImport = - """ + let ifConfigImport = """ #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -63,6 +60,12 @@ struct FoundationImportTests { detectChunkByInitialLines: 1, expectedChunks: [ ifConfigImport + ], + notExpectedChunks: [ + """ + #if canImport(Foundation) + import Foundation + """ ] ) } From 7f1353fc0440ded3524c89dad4fa57addaf4eab4 Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Mon, 16 Mar 2026 10:36:05 +0100 Subject: [PATCH 3/5] format --- .../JExtractSwiftPlugin.swift | 7 +++--- .../Commands/JavaCallbacksBuildCommand.swift | 23 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift index 926f9237..e370bc76 100644 --- a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift +++ b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift @@ -152,7 +152,7 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { executable: toolURL, arguments: arguments, inputFiles: [configFile] + swiftFiles, - outputFiles: jextractOutputFiles + outputFiles: jextractOutputFiles, ) ] @@ -193,7 +193,8 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { log("Detected 'gradle' executable (or gradlew fallback): \(gradleExecutable)") let javaHome = URL(filePath: findJavaHome()) - let javacPath = javaHome + let javacPath = + javaHome .appending(path: "bin") .appending(path: self.javacName) @@ -232,7 +233,7 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { executable: toolURL, arguments: javaCallbacksArguments, inputFiles: outputSwiftFiles + [swiftJavaDirectory], - outputFiles: [javaCallbacksSwiftOutput] + outputFiles: [javaCallbacksSwiftOutput], ) ] diff --git a/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift index 9754d4a8..484fc2b8 100644 --- a/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift +++ b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift @@ -40,7 +40,7 @@ extension SwiftJava { commandName: "java-callbacks-build", abstract: "Build SwiftKitCore, compile Java callbacks, and generate Swift wrappers (for use by JExtractSwiftPlugin)", - shouldDisplay: false + shouldDisplay: false, ) // MARK: Gradle options @@ -78,7 +78,7 @@ extension SwiftJava { @Option( name: .customLong("output-directory"), - help: "Directory where generated Swift files should be written" + help: "Directory where generated Swift files should be written", ) var outputDirectory: String @@ -109,7 +109,7 @@ extension SwiftJava { "--no-daemon", ], environment: .inherit.updating(["GRADLE_USER_HOME": gradleUserHome]), - errorMessage: "gradle :SwiftKitCore:build" + errorMessage: "gradle :SwiftKitCore:build", ) // If the sources list does not exist, jextract produced no Java callbacks. @@ -117,14 +117,17 @@ extension SwiftJava { 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) + to: outputFile, + atomically: true, + encoding: .utf8, + ) return } // 2. Compile Java sources with javac. try FileManager.default.createDirectory( atPath: javaOutputDirectory, - withIntermediateDirectories: true + withIntermediateDirectories: true, ) try await runSubprocess( @@ -135,7 +138,7 @@ extension SwiftJava { "-parameters", "-classpath", swiftKitCoreClasspath, ], - errorMessage: "javac" + errorMessage: "javac", ) // 3. Generate swift-java.config from compiled classes. @@ -154,7 +157,7 @@ extension SwiftJava { try await runSubprocess( executable: swiftJavaTool, arguments: configureArgs, - errorMessage: "swift-java configure" + errorMessage: "swift-java configure", ) // 4. Generate Swift wrappers using wrap-java. @@ -174,7 +177,7 @@ extension SwiftJava { try await runSubprocess( executable: swiftJavaTool, arguments: wrapJavaArgs, - errorMessage: "swift-java wrap-java" + errorMessage: "swift-java wrap-java", ) } } @@ -186,14 +189,14 @@ private func runSubprocess( executable: String, arguments: [String], environment: Subprocess.Environment = .inherit, - errorMessage: String + errorMessage: String, ) async throws { let result = try await Subprocess.run( .path(FilePath(executable)), arguments: .init(arguments), environment: environment, output: .standardOutput, - error: .standardError + error: .standardError, ) guard result.terminationStatus.isSuccess else { throw JavaCallbacksBuildError( From fbb9bd8de1a0a47650c6ab2b167fe8dc975b3cd3 Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Mon, 16 Mar 2026 12:37:31 +0100 Subject: [PATCH 4/5] add comment --- .../Commands/JavaCallbacksBuildCommand.swift | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift index 484fc2b8..759f9875 100644 --- a/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift +++ b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift @@ -22,18 +22,20 @@ import System @preconcurrency import SystemPackage #endif -/// 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 -/// -/// 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. 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( From fa974566b62d7b4cdc17e36718478be45774d2de Mon Sep 17 00:00:00 2001 From: Mads Odgaard Date: Mon, 16 Mar 2026 13:43:50 +0100 Subject: [PATCH 5/5] add linkage tests --- Tests/LinkageTest/Package.swift | 160 ++---------------- .../JExtractLinkageTest.swift | 21 +++ .../Sources/JExtractLinkageTest/main.swift | 19 +++ .../JExtractLinkageTest/swift-java.config | 5 + scripts/run-linkage-test.sh | 53 +++--- 5 files changed, 89 insertions(+), 169 deletions(-) create mode 100644 Tests/LinkageTest/Sources/JExtractLinkageTest/JExtractLinkageTest.swift create mode 100644 Tests/LinkageTest/Sources/JExtractLinkageTest/main.swift create mode 100644 Tests/LinkageTest/Sources/JExtractLinkageTest/swift-java.config 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"