Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 37 additions & 70 deletions Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

@ktoso ktoso Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh oh no... ;-(

// 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 += [
Expand All @@ -148,7 +152,7 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin {
executable: toolURL,
arguments: arguments,
inputFiles: [configFile] + swiftFiles,
outputFiles: jextractOutputFiles
outputFiles: jextractOutputFiles,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pretty sure formatter will dislike this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it passed 🤷

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okey I'm honestly confused with swift-format sometimes... 😅

)
]

Expand Down Expand Up @@ -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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we HAVE to do it all int one huge mega step? Can we not invoke multiple commands one by one? I'd like to make this hidden in command line somehow...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, what would we benefit from writing multiple commands? As this is only intended for internal use and a workaround for the SwiftPM limitation.

"--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],
)
]

Expand Down
213 changes: 213 additions & 0 deletions Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Man I really dislike this command... we'll have to copy around and keep it updated constantly :-\

Before we merge this, can we make sure we can't avoid it? How about just ignoring the java outputs, does swiftpm prevent things from working if we just pretend they don't exist but we write them anyway?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is I am guessing that SwiftPM uses the input/output to derive the dependencies between the build commands. So, I am afraid that it will run the builds in the wrong order.

Alternatively, if its only using it for stale checks, that means that if we remove them from the outputs, we would retrigger those commands on each invocation of the build plugin. Maybe someone from the SwiftPM team can confirm the behaviour here?


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 }
}
1 change: 1 addition & 0 deletions Sources/SwiftJavaTool/SwiftJava.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ struct SwiftJava: AsyncParsableCommand {
ResolveCommand.self,
WrapJavaCommand.self,
JExtractCommand.self,
JavaCallbacksBuildCommand.self,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check how we can hide this form --help? Alternatively, _ prefix it or do something to its name in argument parser etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what the shouldDisplay: false does:

OVERVIEW: Generate sources and configuration for Swift and Java interoperability.

USAGE: swift-java <subcommand>

OPTIONS:
  -h, --help              Show help information.

SUBCOMMANDS:
  configure               Configure and emit a swift-java.config file based on an input dependency or jar file
  resolve                 Resolve dependencies and write the resulting swift-java.classpath file
  wrap-java               Wrap Java classes with corresponding Swift bindings.
  jextract                Wrap Swift functions and types with Java bindings, making them available to be called from Java

]
)

Expand Down
Loading
Loading