diff --git a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift index 10f822cbf..7e57de41c 100644 --- a/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift +++ b/Plugins/JExtractSwiftPlugin/JExtractSwiftPlugin.swift @@ -20,9 +20,14 @@ private let SwiftJavaConfigFileName = "swift-java.config" @main struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { - struct DependentConfigFile { + struct DependencyConfigFile { let swiftModuleName: String + // The specific URL of a swift-java.config file let configURL: URL + // Specific path of sources of this module, usually the same directory where + // swift-java.config is but not always. This can be passed as --depends-on + // if swiftpm cannot find the location automatically though module dependency + let sourceDirURL: URL? } var pluginName: String = "swift-java" @@ -58,7 +63,7 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { let outputJavaDirectory = context.outputJavaDirectory let outputSwiftDirectory = context.outputSwiftDirectory - let dependentConfigFiles = searchForDependentConfigFiles(in: target) + let dependencyConfigFiles = searchForDependencyConfigFiles(in: target) var arguments: [String] = [ /*subcommand=*/"jextract", @@ -83,13 +88,14 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { arguments += ["--static-build-config", resolvedURL.absoluteURL.path(percentEncoded: false)] } - let dependentConfigFilesArguments = dependentConfigFiles.flatMap { dependentConfigFile in - [ - "--depends-on", - "\(dependentConfigFile.swiftModuleName)=\(dependentConfigFile.configURL.path(percentEncoded: false))", - ] + let dependsOnArguments = dependencyConfigFiles.flatMap { dependencyConfigFile -> [String] in + makeDependsOnArgument( + moduleName: dependencyConfigFile.swiftModuleName, + configPath: dependencyConfigFile.configURL.path(percentEncoded: false), + sourcePaths: dependencyConfigFile.sourceDirURL.map { [$0.path(percentEncoded: false)] } ?? [] + ) } - arguments += dependentConfigFilesArguments + arguments += dependsOnArguments let swiftFiles = sourceModule.sourceFiles.map { $0.url }.filter { $0.pathExtension == "swift" @@ -249,7 +255,7 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { "--output-directory", outputSwiftDirectory.path(percentEncoded: false), "--single-swift-file-output", singleSwiftFileOutputName, ] - javaCallbacksArguments += dependentConfigFilesArguments + javaCallbacksArguments += dependsOnArguments commands += [ .buildCommand( @@ -275,8 +281,8 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { /// Find the manifest files from other swift-java executions in any targets /// this target depends on. - func searchForDependentConfigFiles(in target: any Target) -> [DependentConfigFile] { - var dependentConfigFiles: [DependentConfigFile] = [] + func searchForDependencyConfigFiles(in target: any Target) -> [DependencyConfigFile] { + var dependencyConfigFiles: [DependencyConfigFile] = [] func _searchForConfigFiles(in target: any Target) { // log("Search for config files in target: \(target.name)") @@ -291,8 +297,12 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { .path(percentEncoded: false) if FileManager.default.fileExists(atPath: dependencyConfigString) { - dependentConfigFiles.append( - DependentConfigFile(swiftModuleName: target.name, configURL: dependencyConfigURL) + dependencyConfigFiles.append( + DependencyConfigFile( + swiftModuleName: target.name, + configURL: dependencyConfigURL, + sourceDirURL: target.sourceModule?.directoryURL, + ) ) } } @@ -322,7 +332,7 @@ struct JExtractSwiftBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { _searchForConfigFiles(in: dependency) } - return dependentConfigFiles + return dependencyConfigFiles } private func findSwiftJavaDirectory(for target: any Target) -> URL? { diff --git a/Plugins/PluginsShared/DependsOnArgument.swift b/Plugins/PluginsShared/DependsOnArgument.swift new file mode 100644 index 000000000..739b3d93e --- /dev/null +++ b/Plugins/PluginsShared/DependsOnArgument.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 +// +//===----------------------------------------------------------------------===// + +/// Build a single `--depends-on` argument pair: `["--depends-on", ""]`. +/// +/// Value form: `[=][,...]`. +func makeDependsOnArgument( + moduleName: String? = nil, + configPath: String, + sourcePaths: [String] = [] +) -> [String] { + var value: String + if let moduleName, !moduleName.isEmpty { + value = "\(moduleName)=\(configPath)" + } else { + value = configPath + } + if !sourcePaths.isEmpty { + value += "," + sourcePaths.joined(separator: ",") + } + return ["--depends-on", value] +} diff --git a/Plugins/SwiftJavaPlugin/SwiftJavaPlugin.swift b/Plugins/SwiftJavaPlugin/SwiftJavaPlugin.swift index ec60ee418..cac1f7587 100644 --- a/Plugins/SwiftJavaPlugin/SwiftJavaPlugin.swift +++ b/Plugins/SwiftJavaPlugin/SwiftJavaPlugin.swift @@ -19,7 +19,7 @@ private let SwiftJavaConfigFileName = "swift-java.config" @main struct SwiftJavaBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { - struct DependentConfigFile { + struct DependencyConfigFile { let swiftModuleName: String let configURL: URL } @@ -50,7 +50,7 @@ struct SwiftJavaBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { /// Find the manifest files from other swift-java executions in any targets /// this target depends on. - var dependentConfigFiles: [DependentConfigFile] = [] + var dependencyConfigFiles: [DependencyConfigFile] = [] func searchForConfigFiles(in target: any Target) { // log("Search for config files in target: \(target.name)") let dependencyURL = target.directoryURL @@ -64,8 +64,8 @@ struct SwiftJavaBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { .path(percentEncoded: false) if FileManager.default.fileExists(atPath: dependencyConfigString) { - dependentConfigFiles.append( - DependentConfigFile(swiftModuleName: target.name, configURL: dependencyConfigURL) + dependencyConfigFiles.append( + DependencyConfigFile(swiftModuleName: target.name, configURL: dependencyConfigURL) ) } } @@ -98,7 +98,7 @@ struct SwiftJavaBuildToolPlugin: SwiftJavaPluginProtocol, BuildToolPlugin { var arguments: [String] = [] arguments += argumentsSwiftModule(sourceModule: sourceModule) arguments += argumentsOutputDirectory(context: context) - arguments += argumentsDependedOnConfigs(dependentConfigFiles) + arguments += dependsOnArguments(dependencyConfigFiles) let classes = config.classes ?? [:] print("[swift-java-plugin] Classes to wrap (\(classes.count)): \(classes.map(\.key))") @@ -230,12 +230,12 @@ extension SwiftJavaBuildToolPlugin { ] } - func argumentsDependedOnConfigs(_ dependentConfigFiles: [DependentConfigFile]) -> [String] { - dependentConfigFiles.flatMap { dependentConfigFile in - [ - "--depends-on", - "\(dependentConfigFile.swiftModuleName)=\(dependentConfigFile.configURL.path(percentEncoded: false))", - ] + func dependsOnArguments(_ dependencyConfigFiles: [DependencyConfigFile]) -> [String] { + dependencyConfigFiles.flatMap { dependencyConfigFile in + makeDependsOnArgument( + moduleName: dependencyConfigFile.swiftModuleName, + configPath: dependencyConfigFile.configURL.path(percentEncoded: false) + ) } } diff --git a/Samples/SwiftJavaExtractJNISampleApp/Package.swift b/Samples/SwiftJavaExtractJNISampleApp/Package.swift index cf72cc50b..4f86b1a9e 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/Package.swift +++ b/Samples/SwiftJavaExtractJNISampleApp/Package.swift @@ -14,15 +14,20 @@ let package = Package( name: "MySwiftLibrary", type: .dynamic, targets: ["MySwiftLibrary"] - ) - + ), + .library( + name: "MySwiftDependencyLibrary", + type: .dynamic, + targets: ["MySwiftDependencyLibrary"] + ), ], dependencies: [ .package(name: "swift-java", path: "../../") ], targets: [ + // Separate module to show that we can handle cross module type references (automatic --depends-on) .target( - name: "MySwiftLibrary", + name: "MySwiftDependencyLibrary", dependencies: [ .product(name: "SwiftJava", package: "swift-java") ], @@ -35,6 +40,22 @@ let package = Package( plugins: [ .plugin(name: "JExtractSwiftPlugin", package: "swift-java") ] - ) + ), + .target( + name: "MySwiftLibrary", + dependencies: [ + .product(name: "SwiftJava", package: "swift-java"), + "MySwiftDependencyLibrary", + ], + exclude: [ + "swift-java.config" + ], + swiftSettings: [ + .swiftLanguageMode(.v5) + ], + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") + ] + ), ] ) diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftDependencyLibrary/ValueInDependencyModule.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftDependencyLibrary/ValueInDependencyModule.swift new file mode 100644 index 000000000..a0074dc6f --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftDependencyLibrary/ValueInDependencyModule.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +public struct ValueInDependencyModule { + public let value: Int32 + + public init(value: Int32) { + self.value = value + } +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftDependencyLibrary/swift-java.config b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftDependencyLibrary/swift-java.config new file mode 100644 index 000000000..c7745faae --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftDependencyLibrary/swift-java.config @@ -0,0 +1,4 @@ +{ + "javaPackage": "com.example.swift.dep", + "mode": "jni", +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ConsumeValueFromOtherModule.swift b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ConsumeValueFromOtherModule.swift new file mode 100644 index 000000000..e77f333c5 --- /dev/null +++ b/Samples/SwiftJavaExtractJNISampleApp/Sources/MySwiftLibrary/ConsumeValueFromOtherModule.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// 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 MySwiftDependencyLibrary + +// Show using a type from another module +// This depends on --depends-on being passed correctly +public func consumeValueFromOtherModule(_ v: ValueInDependencyModule) -> Int32 { + v.value + 1 +} diff --git a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/MySwiftLibraryTest.java b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/MySwiftLibraryTest.java index 22232b620..b6d6501d8 100644 --- a/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/MySwiftLibraryTest.java +++ b/Samples/SwiftJavaExtractJNISampleApp/src/test/java/com/example/swift/MySwiftLibraryTest.java @@ -18,6 +18,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.swift.swiftkit.core.SwiftArena; import java.util.Arrays; import java.util.concurrent.CountDownLatch; @@ -85,4 +86,13 @@ void labeledOverloads() { assertEquals(202, MySwiftLibrary.globalOverloadedB(200)); assertEquals(303, MySwiftLibrary.globalOverloaded(300)); } + + @Test + void call_consumeValueFromOtherModule_crossModule() { + try (var arena = SwiftArena.ofConfined()) { + var value = com.example.swift.dep.ValueInDependencyModule.init(41, arena); + int result = MySwiftLibrary.consumeValueFromOtherModule(value); + assertEquals(42, result); + } + } } diff --git a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+SwiftThunkPrinting.swift index 1bc52d6e2..ccecc17ee 100644 --- a/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/FFM/FFMSwift2JavaGenerator+SwiftThunkPrinting.swift @@ -28,12 +28,13 @@ extension FFMSwift2JavaGenerator { return // no need to write any empty files, yay } - log.info( - "[swift-java] Write empty [\(self.expectedOutputSwiftFileNames.count)] 'expected' files in: \(swiftOutputDirectory)/" + log.debug( + "Write empty [\(self.expectedOutputSwiftFileNames.count)] 'expected' files in: \(swiftOutputDirectory)/" ) + // FIXME(SwiftPM): We'd like to avoid having to write these blank files for expectedFileName in self.expectedOutputSwiftFileNames { - log.info("Write SwiftPM-'expected' empty file: \(expectedFileName.bold)") + log.trace("Write SwiftPM-'expected' empty file: \(expectedFileName.bold)") var printer = CodePrinter() printer.print("// Empty file generated on purpose") diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift index c3e1eb5c7..3f6797f1b 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+SwiftThunkPrinting.swift @@ -35,12 +35,13 @@ extension JNISwift2JavaGenerator { return // no need to write any empty files, yay } - logger.info( + logger.debug( "Write empty [\(self.expectedOutputSwiftFileNames.count)] 'expected' files in: \(swiftOutputDirectory)/" ) + // FIXME(SwiftPM): We'd like to avoid having to write these blank files for expectedFileName in self.expectedOutputSwiftFileNames { - logger.info("Write SwiftPM-'expected' empty file: \(expectedFileName.bold)") + logger.trace("Write SwiftPM-'expected' empty file: \(expectedFileName.bold)") var printer = CodePrinter() printer.print("// Empty file generated on purpose") diff --git a/Sources/JExtractSwiftLib/SourceDependencies.swift b/Sources/JExtractSwiftLib/SourceDependencies.swift new file mode 100644 index 000000000..a1128e51b --- /dev/null +++ b/Sources/JExtractSwiftLib/SourceDependencies.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import SwiftJavaConfigurationShared +import SwiftParser +import SwiftSyntax + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + + +package typealias SwiftModuleName = String +package typealias SwiftTypeName = String +package typealias SwiftSourceText = String +package typealias JavaClassName = String +package typealias JavaFullyQualifiedClassName = String +package typealias JavaPackageName = String + +/// Holds the inputs jextract needs for symbol resolution but does not generate +/// bindings for. +/// +/// Two flavours of "dependency" are tracked: +/// - Wrapped Java classes referenced from this module's API (e.g. `JavaInteger`). +/// - Real Swift sources from dependency Swift modules (passed via `--depends-on`), +/// parsed once and registered as imported `SwiftModuleSymbolTable`s so that +/// cross-module type references in this module's API can resolve them. +package struct SourceDependencies { + /// Swift wrapper type names for Java classes referenced from this module's + /// API (by convention `Java`, e.g. `JavaVector`). + package var javaClasses: [SwiftTypeName] = [] + + /// Parsed Swift inputs from dependency modules, keyed by Swift module name. + package var swiftModuleInputs: [SwiftModuleName: [SwiftJavaInputFile]] = [:] + + package init() {} + + /// Names of all dependency modules with associated Swift sources. + package var swiftModuleNames: Dictionary.Keys { + swiftModuleInputs.keys + } + + /// Synthetic Swift source registering `@JavaClass public class {}` stubs + package var syntheticJavaWrappersSwiftSource: SwiftJavaInputFile? { + guard !javaClasses.isEmpty else { return nil } + let text = + javaClasses + .map { "@JavaClass public class \($0) {}" } + .joined(separator: "\n") + return SwiftJavaInputFile( + syntax: Parser.parse(source: text), + path: ".swift" + ) + } + + package mutating func loadSwiftSources(from dependency: DependencyConfig, log: Logger) { + guard let moduleName = dependency.swiftModuleName else { + log.debug( + "Skipping anonymous '--depends-on' entry (no '=' prefix); cross-module type references from it cannot be resolved." + ) + return + } + guard !dependency.swiftSourcePaths.isEmpty else { + log.warning( + "Dependency module '\(moduleName)' has no resolvable Swift sources; cross-module type references will fail to import. Pass an explicit '--depends-on \(moduleName)=,' or set 'inputSwiftDirectory' in its swift-java.config." + ) + return + } + + let files = collectAllFiles( + suffix: ".swift", + in: dependency.swiftSourcePaths, + log: log, + ) + var inputs: [SwiftJavaInputFile] = [] + let fm = FileManager.default + for url in files where canExtract(from: url) { + guard + let data = fm.contents(atPath: url.path), + let text = String(data: data, encoding: .utf8) + else { continue } + let syntax = Parser.parse(source: text) + inputs.append(SwiftJavaInputFile(syntax: syntax, path: url.path)) + } + + if inputs.isEmpty { + log.warning( + "Dependency module '\(moduleName)' source paths \(dependency.swiftSourcePaths.map(\.path)) contained no extractable .swift files." + ) + return + } + log.info( + "Loaded \(inputs.count) source file(s) for dependency module '\(moduleName)' from \(dependency.swiftSourcePaths.map(\.path))" + ) + swiftModuleInputs[moduleName] = inputs + } +} diff --git a/Sources/JExtractSwiftLib/Swift2Java.swift b/Sources/JExtractSwiftLib/Swift2Java.swift index 25084632f..4350ffcde 100644 --- a/Sources/JExtractSwiftLib/Swift2Java.swift +++ b/Sources/JExtractSwiftLib/Swift2Java.swift @@ -16,16 +16,17 @@ import Foundation import OrderedCollections import SwiftJavaConfigurationShared import SwiftJavaShared +import SwiftParser import SwiftSyntax import SwiftSyntaxBuilder public struct SwiftToJava { let config: Configuration - let dependentConfigs: [DependentConfig] + let dependencyConfigs: [DependencyConfig] - public init(config: Configuration, dependentConfigs: [DependentConfig]) { + public init(config: Configuration, dependencyConfigs: [DependencyConfig]) { self.config = config - self.dependentConfigs = dependentConfigs + self.dependencyConfigs = dependencyConfigs } public func run() throws { @@ -95,13 +96,13 @@ public struct SwiftToJava { fatalError("Missing --output-java directory!") } - let wrappedJavaClassesLookupTable: JavaClassLookupTable = dependentConfigs.compactMap(\.configuration.classes).reduce(into: [:]) { + let wrappedJavaClassesLookupTable: JavaClassLookupTable = dependencyConfigs.compactMap(\.configuration.classes).reduce(into: [:]) { for (canonicalName, javaClass) in $1 { $0[javaClass] = canonicalName } } - let moduleJavaPackages = dependentConfigs.reduce(into: [String: String]()) { partialResult, dependency in + let moduleJavaPackages = dependencyConfigs.reduce(into: [String: String]()) { partialResult, dependency in guard let moduleName = dependency.swiftModuleName, let javaPackage = dependency.configuration.javaPackage, @@ -112,7 +113,10 @@ public struct SwiftToJava { partialResult[moduleName] = javaPackage } - translator.dependenciesClasses = Array(wrappedJavaClassesLookupTable.keys) + translator.sourceDependencies.javaClasses = Array(wrappedJavaClassesLookupTable.keys) + for config in dependencyConfigs { + translator.sourceDependencies.loadSwiftSources(from: config, log: translator.log) + } try translator.analyze() @@ -145,17 +149,6 @@ public struct SwiftToJava { print("[swift-java] Imported Swift module '\(swiftModule)': " + "done.".green) } - func canExtract(from file: URL) -> Bool { - guard file.lastPathComponent.hasSuffix(".swift") || file.lastPathComponent.hasSuffix(".swiftinterface") else { - return false - } - if file.lastPathComponent.hasSuffix("+SwiftJava.swift") { - return false - } - - return true - } - /// Compute a relative path (sans `.swift` extension) for a file against the /// input paths, suitable for jextract filter matching func computeRelativePath(file: URL, inputPaths: [URL]) -> String { @@ -173,7 +166,17 @@ public struct SwiftToJava { // Fallback: just the filename return file.lastPathComponent } +} + +func canExtract(from file: URL) -> Bool { + guard file.lastPathComponent.hasSuffix(".swift") || file.lastPathComponent.hasSuffix(".swiftinterface") else { + return false + } + if file.lastPathComponent.hasSuffix("+SwiftJava.swift") { + return false + } + return true } extension URL { diff --git a/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift b/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift index 1be7e04be..d912dee1b 100644 --- a/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift +++ b/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift @@ -43,8 +43,10 @@ public final class Swift2JavaTranslator { /// complain about missing declared outputs var filteredOutPaths: [String] = [] - /// A list of used Swift class names that live in dependencies, e.g. `JavaInteger` - package var dependenciesClasses: [String] = [] + /// Sources jextract needs for symbol resolution but does not generate bindings + /// for: wrapped Java classes plus real Swift sources from dependency modules. + /// Populated by `SwiftToJava.run` before `analyze()` runs. + package var sourceDependencies = SourceDependencies() // ==== Output state @@ -198,12 +200,11 @@ extension Swift2JavaTranslator { } package func prepareForTranslation() { - let dependenciesSource = self.buildDependencyClassesSourceFile() - let symbolTable = SwiftSymbolTable.setup( moduleName: self.swiftModuleName, - inputs + [dependenciesSource], + inputs, config: self.config, + sourceDependencies: self.sourceDependencies, buildConfig: self.buildConfig, log: self.log, ) @@ -264,17 +265,6 @@ extension Swift2JavaTranslator { } return false } - - /// Returns a source file that contains all the available dependency classes. - private func buildDependencyClassesSourceFile() -> SwiftJavaInputFile { - let contents = self.dependenciesClasses.map { - "@JavaClass public class \($0) {}" - } - .joined(separator: "\n") - - let syntax = SourceFileSyntax(stringLiteral: contents) - return SwiftJavaInputFile(syntax: syntax, path: "FakeDependencyClassesSourceFile.swift") - } } // ==== ---------------------------------------------------------------------------------------------------------------- @@ -306,10 +296,10 @@ extension Swift2JavaTranslator { return nil } - // Whether to import this extension? let isFromThisModule = swiftNominalDecl.moduleName == self.swiftModuleName let isFromStubbedModule = config.hasImportedModuleStub(moduleOfNominal: swiftNominalDecl.moduleName) - guard isFromThisModule || isFromStubbedModule else { + let isFromDependencyModule = sourceDependencies.swiftModuleNames.contains(swiftNominalDecl.moduleName) + guard isFromThisModule || isFromStubbedModule || isFromDependencyModule else { return nil } diff --git a/Sources/JExtractSwiftLib/Swift2JavaVisitor.swift b/Sources/JExtractSwiftLib/Swift2JavaVisitor.swift index ca2c3c61a..ed6b4ed8b 100644 --- a/Sources/JExtractSwiftLib/Swift2JavaVisitor.swift +++ b/Sources/JExtractSwiftLib/Swift2JavaVisitor.swift @@ -201,7 +201,11 @@ final class Swift2JavaVisitor { lookupContext: translator.lookupContext, ) } catch { - self.log.debug("Failed to import: '\(node.qualifiedNameForDebug)'; \(error)") + self.log.warning( + Self.makeMissingTypeMessage( + "Failed to import: '\(node.qualifiedNameForDebug)' in module '\(translator.swiftModuleName)'; \(error)" + ) + ) return } @@ -264,7 +268,11 @@ final class Swift2JavaVisitor { typeContext.cases.append(importedCase) } } catch { - self.log.debug("Failed to import: \(node.qualifiedNameForDebug); \(error)") + self.log.warning( + Self.makeMissingTypeMessage( + "Failed to import: \(node.qualifiedNameForDebug) in module '\(translator.swiftModuleName)'; \(error)" + ) + ) } } @@ -304,7 +312,11 @@ final class Swift2JavaVisitor { ) } } catch { - self.log.debug("Failed to import: \(node.qualifiedNameForDebug); \(error)") + self.log.warning( + Self.makeMissingTypeMessage( + "Failed to import: \(node.qualifiedNameForDebug) in module '\(translator.swiftModuleName)'; \(error)" + ) + ) } } @@ -335,7 +347,11 @@ final class Swift2JavaVisitor { lookupContext: translator.lookupContext, ) } catch { - self.log.debug("Failed to import: \(node.qualifiedNameForDebug); \(error)") + self.log.warning( + Self.makeMissingTypeMessage( + "Failed to import: \(node.qualifiedNameForDebug) in module '\(translator.swiftModuleName)'; \(error)" + ) + ) return } let imported = ImportedFunc( @@ -382,7 +398,11 @@ final class Swift2JavaVisitor { ) } } catch { - self.log.debug("Failed to import: \(node.qualifiedNameForDebug); \(error)") + self.log.warning( + Self.makeMissingTypeMessage( + "Failed to import: \(node.qualifiedNameForDebug) in module '\(translator.swiftModuleName)'; \(error)" + ) + ) } } @@ -685,6 +705,10 @@ final class Swift2JavaVisitor { } return true } + + static func makeMissingTypeMessage(_ message: String) -> String { + "\(message). If the unresolved type lives in another Swift module, declare it as a SwiftPM target dependency with its own swift-java.config (the JExtractSwiftPlugin wires --depends-on automatically), or pass --depends-on = explicitly." + } } extension DeclSyntaxProtocol where Self: WithModifiersSyntax & WithAttributesSyntax { diff --git a/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift b/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift index ea0b085dd..a4c4de73b 100644 --- a/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift +++ b/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift @@ -90,6 +90,7 @@ extension SwiftSymbolTable { moduleName: String, _ inputFiles: some Collection, config: Configuration?, + sourceDependencies: SourceDependencies, buildConfig: any BuildConfiguration = .jextractDefault, log: Logger, ) -> SwiftSymbolTable { @@ -115,6 +116,34 @@ extension SwiftSymbolTable { } } + for dependencyModuleName in sourceDependencies.swiftModuleNames { + // The module may already have been loaded as a known/built-in module + // (e.g. Swift, Foundation) above + guard importedModules[dependencyModuleName] == nil else { + continue + } + let dependencyInputs = sourceDependencies.swiftModuleInputs[dependencyModuleName] ?? [] + // TODO: build a `dependencyImportedModules` dict by scanning the dep's + // own source files with `importingModules(sourceFile:)`, instead of + // reusing the primary's `importedModules`. The current set is too broad + // (it can shadow names) and too narrow (it misses modules the dep + // imports but the primary doesn't). + var dependencyModuleBuilder = SwiftParsedModuleSymbolTableBuilder( + moduleName: dependencyModuleName, + importedModules: importedModules, + buildConfig: buildConfig, + ) + for input in dependencyInputs { + dependencyModuleBuilder.handle(sourceFile: input.syntax, sourceFilePath: input.path) + } + let dependencyModule = dependencyModuleBuilder.finalize() + importedModules[dependencyModuleName] = dependencyModule + log.info( + "Loaded dependency module '\(dependencyModuleName)' from \(dependencyInputs.count) source(s); " + + "top-level types [\(dependencyModule.topLevelTypes.count)]: \(dependencyModule.topLevelTypes.keys.sorted())" + ) + } + // Load stub type declarations for imported modules from config. // This enables types from external modules (e.g. extension targets) to be // resolved in the symbol table without scanning their actual source. @@ -125,7 +154,7 @@ extension SwiftSymbolTable { let sourceFile = Parser.parse(source: source) var stubBuilder = SwiftParsedModuleSymbolTableBuilder( moduleName: stubModuleName, - importedModules: ["Swift": importedModules["Swift"]!], + importedModules: importedModules, buildConfig: buildConfig, ) stubBuilder.handle(sourceFile: sourceFile, sourceFilePath: "\(stubModuleName)_stub.swift") @@ -152,6 +181,9 @@ extension SwiftSymbolTable { for sourceFile in inputFiles { builder.handle(sourceFile: sourceFile.syntax, sourceFilePath: sourceFile.path) } + if let stubs = sourceDependencies.syntheticJavaWrappersSwiftSource { + builder.handle(sourceFile: stubs.syntax, sourceFilePath: stubs.path) + } let parsedModule = builder.finalize() return SwiftSymbolTable(parsedModule: parsedModule, importedModules: importedModules) } diff --git a/Sources/JExtractSwiftLib/SwiftTypes/SwiftTypeLookupContext.swift b/Sources/JExtractSwiftLib/SwiftTypes/SwiftTypeLookupContext.swift index ffc1154b8..664fb7463 100644 --- a/Sources/JExtractSwiftLib/SwiftTypes/SwiftTypeLookupContext.swift +++ b/Sources/JExtractSwiftLib/SwiftTypes/SwiftTypeLookupContext.swift @@ -137,14 +137,14 @@ class SwiftTypeLookupContext { // For extensions, we need to resolve the extended type to find the // actual nominal type declaration. The extended type might be a simple // identifier (e.g. `extension Foo`) or a member type - // (e.g. `extension P256._ARCV1`). + // (e.g. `extension Outer.Inner`). if case .identifierType(let id) = Syntax(node.extendedType).as(SyntaxEnum.self), let lookupResult = try unqualifiedLookup(name: Identifier(id.name)!, from: node) { typeDecl = lookupResult } else { - // For member types (e.g. P256._ARCV1), resolve through SwiftType + // For member types (e.g. Outer.Inner), resolve through SwiftType let swiftType = try SwiftType(node.extendedType, lookupContext: self) guard let nominalDecl = swiftType.asNominalTypeDeclaration else { throw TypeLookupError.notType(Syntax(node)) diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index 533c371a1..78608b4b5 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -426,34 +426,122 @@ public func readConfiguration( } } -/// Parsed dependent configuration provided via `--depends-on`. -public struct DependentConfig { +/// Parsed dependency configuration provided via `--depends-on`. +public struct DependencyConfig { public let swiftModuleName: String? public let configuration: Configuration - public init(swiftModuleName: String?, configuration: Configuration) { + /// Absolute paths to the dependency module's Swift source directories (or individual files). + /// + /// Populated by `parseDependsOnSyntax` from, in order: + /// 1. Explicit `,` suffixes on the `--depends-on` argument. + /// 2. The dependency's `configuration.inputSwiftDirectory`, resolved relative to the + /// directory containing the config file. + /// 3. `/Sources//` if it exists (SwiftPM convention). + /// 4. The directory containing the config file. + /// + /// Empty only when none of the above could be resolved; in that case cross-module + /// type lookups for this dependency will fail and `jextract` will log a warning. + public let swiftSourcePaths: [URL] + + public init(swiftModuleName: String?, configuration: Configuration, swiftSourcePaths: [URL] = []) { self.swiftModuleName = swiftModuleName self.configuration = configuration + self.swiftSourcePaths = swiftSourcePaths } } -/// Load all dependent configs configured with `--depends-on`. -public func loadDependentConfigs(dependsOn: [String]) throws -> [DependentConfig] { - try dependsOn.map { dependentConfig in - let equalLoc = dependentConfig.firstIndex(of: "=") +/// Load all dependency configs configured with `--depends-on`. +/// +/// Argument grammar: `[=][,...]`. +/// +/// The optional comma-separated sources paths override the default inference chain +/// described on ``DependencyConfig/swiftSourcePaths``. +public func parseDependsOnSyntax(dependsOn: [String]) throws -> [DependencyConfig] { + try dependsOn.map(parseDependsOnSyntax) +} - var swiftModuleName: String? = nil - if let equalLoc { - swiftModuleName = String(dependentConfig[.. DependencyConfig { + let equalLoc = dependsOn.firstIndex(of: "=") + + var swiftModuleName: String? = nil + if let equalLoc { + swiftModuleName = String(dependsOn[.. [URL] { + if !explicit.isEmpty { + return explicit + } + + let configParent = configURL.deletingLastPathComponent() + let fm = FileManager.default + + // 2) Dependency config's inputSwiftDirectory, relative to the config file's directory. + if let input = configuration.inputSwiftDirectory, !input.isEmpty { + let parts = input.split(separator: ",", omittingEmptySubsequences: true).map(String.init) + let urls: [URL] = parts.map { part in + let url = URL(fileURLWithPath: part, relativeTo: configParent).absoluteURL + return url + } + if urls.allSatisfy({ fm.fileExists(atPath: $0.path) }) { + return urls + } + } + + // 3) /Sources// (SwiftPM convention). + if let moduleName, !moduleName.isEmpty { + let candidate = + configParent + .appendingPathComponent("Sources", isDirectory: true) + .appendingPathComponent(moduleName, isDirectory: true) + if fm.fileExists(atPath: candidate.path) { + return [candidate] + } } + + // 4) Config file's own parent directory. + guard fm.fileExists(atPath: configParent.path) else { + return [] + } + return [configParent] } public func findSwiftJavaClasspaths(swiftModule: String) -> [String] { @@ -538,6 +626,13 @@ public struct ConfigurationError: Error { } } +public struct EmptyDependsOnArgumentError: Error, CustomStringConvertible { + public let argument: String + public var description: String { + "Empty '--depends-on' argument: '\(argument)'" + } +} + // ==== ----------------------------------------------------------------------- // MARK: SpecializationConfigEntry diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftPMPlugin.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftPMPlugin.md index e72f9cfa8..dd97224f6 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftPMPlugin.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftPMPlugin.md @@ -50,3 +50,14 @@ let package = Package( ``` > Note: Depending on the use case, swift-java may require running Gradle or accessing files outside the Swift package. Ensure that your environment allows Gradle to run, and add the `--disable-sandbox` parameter when invoking the `swift build` command to build the package. + +### Handling cross module Swift type dependencies + +Sometimes you may be wanting to treat a specific module with swift-java jextract and expose it to Java, only to find +that it is also exposing types from other modules. + +In this situation it is best to also add a `swift-java.config` configuration into the other module, +and configure it appropriately. Next, when you run the plugin in the main module, it will automatically +pick up the dependency (since your Swift module depends on the other one) and detect there is swift-java configuration there. + +This informs the source generator about the location and package of the generated sources and allows it to compile the generated sources in your main module. \ No newline at end of file diff --git a/Sources/SwiftJavaTool/Commands/JExtractCommand.swift b/Sources/SwiftJavaTool/Commands/JExtractCommand.swift index cd58d1f19..f901eb731 100644 --- a/Sources/SwiftJavaTool/Commands/JExtractCommand.swift +++ b/Sources/SwiftJavaTool/Commands/JExtractCommand.swift @@ -174,11 +174,11 @@ extension SwiftJava.JExtractCommand { print("[debug][swift-java] Running 'swift-java jextract' in mode: " + "\(config.effectiveMode)".bold) - // Load all of the dependent configurations and associate them with Swift modules. - let dependentConfigs = try loadDependentConfigs(dependsOn: self.dependsOn) - print("[debug][swift-java] Dependent configs: \(dependentConfigs.count)") + // Load all of the dependency configurations and associate them with Swift modules. + let dependencyConfigs = try parseDependsOnSyntax(dependsOn: self.dependsOn) + print("[debug][swift-java] Dependency configs: \(dependencyConfigs.count)") - try jextractSwift(config: config, dependentConfigs: dependentConfigs) + try jextractSwift(config: config, dependencyConfigs: dependencyConfigs) } /// Check if the configured modes are compatible, and fail if not @@ -207,9 +207,9 @@ struct IncompatibleModeError: Error { extension SwiftJava.JExtractCommand { func jextractSwift( config: Configuration, - dependentConfigs: [DependentConfig], + dependencyConfigs: [DependencyConfig], ) throws { - try SwiftToJava(config: config, dependentConfigs: dependentConfigs).run() + try SwiftToJava(config: config, dependencyConfigs: dependencyConfigs).run() } } diff --git a/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift index 759f98753..3d737f047 100644 --- a/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift +++ b/Sources/SwiftJavaTool/Commands/JavaCallbacksBuildCommand.swift @@ -15,6 +15,7 @@ import ArgumentParser import Foundation import Subprocess +import SwiftJavaConfigurationShared #if canImport(System) import System @@ -92,7 +93,7 @@ extension SwiftJava { @Option( help: - "Dependent module configurations (format: ModuleName=/path/to/swift-java.config)" + "Dependency module configurations (format: ModuleName=/path/to/swift-java.config)" ) var dependsOn: [String] = [] @@ -132,14 +133,27 @@ extension SwiftJava { withIntermediateDirectories: true, ) + // Dependency modules jextract writes to their respective directories, so + // we need to consider them when we try to compile java output + let dependencySourcePaths = dependencyJavaSourceDirs( + javaSourcesList: javaSourcesList, + dependsOn: dependsOn + ) + + var javacArgs: [String] = [ + "@\(javaSourcesList)", + "-d", javaOutputDirectory, + "-parameters", + "-classpath", swiftKitCoreClasspath, + ] + // Consider dependency modules generated java sources as well + if !dependencySourcePaths.isEmpty { + javacArgs += ["-sourcepath", dependencySourcePaths.joined(separator: ":")] + } + try await runSubprocess( executable: javac, - arguments: [ - "@\(javaSourcesList)", - "-d", javaOutputDirectory, - "-parameters", - "-classpath", swiftKitCoreClasspath, - ], + arguments: javacArgs, errorMessage: "javac", ) @@ -187,6 +201,64 @@ extension SwiftJava { // MARK: - Helpers +/// Find the plugin output path, walking up from a emitted generated source file +private func pluginOutputsRoot(forJavaSourcesList javaSourcesList: String) -> URL { + let url = URL(fileURLWithPath: javaSourcesList) + // Validate the expected SwiftPM plugin-outputs layout before stripping suffix. + let expectedSuffix = [ + "destination", + "JExtractSwiftPlugin", + "src", + "generated", + "java", + "jextract-generated-sources.txt", + ] + let comps = url.pathComponents + precondition( + comps.count >= expectedSuffix.count + 2 + && Array(comps.suffix(expectedSuffix.count)) == expectedSuffix, + "javaSourcesList does not match the expected SwiftPM plugin-outputs layout: \(javaSourcesList)" + ) + // Walk up: trailing fixed components + 1 consumer-module directory + var root = url + for _ in 0..<(expectedSuffix.count + 1) { + root.deleteLastPathComponent() + } + return root +} + +/// For each `--depends-on Module=...` entry, derive the dependency module's +/// generated Java directory. +private func dependencyJavaSourceDirs( + javaSourcesList: String, + dependsOn: [String] +) -> [String] { + let pluginRoot = pluginOutputsRoot(forJavaSourcesList: javaSourcesList) + let fm = FileManager.default + var seen: Set = [] + var paths: [String] = [] + for arg in dependsOn { + guard + let parsed = try? parseDependsOnSyntax(arg), + let moduleName = parsed.swiftModuleName, !moduleName.isEmpty + else { continue } + let candidate = + pluginRoot + .appendingPathComponent(moduleName) + .appendingPathComponent("destination") + .appendingPathComponent("JExtractSwiftPlugin") + .appendingPathComponent("src") + .appendingPathComponent("generated") + .appendingPathComponent("java") + let path = candidate.path + guard fm.fileExists(atPath: path), seen.insert(path).inserted else { + continue // already handled this module + } + paths.append(path) + } + return paths +} + private func runSubprocess( executable: String, arguments: [String], diff --git a/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift b/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift index d9f25bb51..cda9a445d 100644 --- a/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift +++ b/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift @@ -78,7 +78,7 @@ extension SwiftJava { } extension SwiftJava.WrapJavaCommand { - struct NamedDependentConfig { + struct NamedDependencyConfig { let swiftModuleName: String let configuration: Configuration } @@ -104,24 +104,24 @@ extension SwiftJava.WrapJavaCommand { log: Self.log ) - // Load all of the dependent configurations and associate them with Swift modules. - let dependentConfigs = try loadDependentConfigs(dependsOn: self.dependsOn).map { dependentConfig in - guard let moduleName = dependentConfig.swiftModuleName else { + // Load all of the dependency configurations and associate them with Swift modules. + let dependencyConfigs = try parseDependsOnSyntax(dependsOn: self.dependsOn).map { dependencyConfig in + guard let moduleName = dependencyConfig.swiftModuleName else { throw JavaToSwiftError.badConfigOption(self.dependsOn.joined(separator: " ")) } - return NamedDependentConfig(swiftModuleName: moduleName, configuration: dependentConfig.configuration) + return NamedDependencyConfig(swiftModuleName: moduleName, configuration: dependencyConfig.configuration) } - print("[debug][swift-java] Dependent configs: \(dependentConfigs.count)") + print("[debug][swift-java] Dependency configs: \(dependencyConfigs.count)") // Include classpath entries which libs we depend on require... - for dependentConfig in dependentConfigs { + for dependencyConfig in dependencyConfigs { print( - "[trace][swift-java] Add dependent config (\(dependentConfig.swiftModuleName)) classpath elements: \(dependentConfig.configuration.classpathEntries.count)" + "[trace][swift-java] Add dependency config (\(dependencyConfig.swiftModuleName)) classpath elements: \(dependencyConfig.configuration.classpathEntries.count)" ) - // TODO: may need to resolve the dependent configs rather than just get their configs + // TODO: may need to resolve the dependency configs rather than just get their configs // TODO: We should cache the resolved classpaths as well so we don't do it many times - for entry in dependentConfig.configuration.classpathEntries { - print("[trace][swift-java] Add dependent config (\(dependentConfig.swiftModuleName)) classpath element: \(entry)") + for entry in dependencyConfig.configuration.classpathEntries { + print("[trace][swift-java] Add dependency config (\(dependencyConfig.swiftModuleName)) classpath element: \(entry)") classpathEntries.append(entry) } } @@ -131,7 +131,7 @@ extension SwiftJava.WrapJavaCommand { try self.generateWrappers( config: config, // classpathEntries: classpathEntries, - dependentConfigs: dependentConfigs, + dependencyConfigs: dependencyConfigs, environment: jvm.environment() ) } @@ -141,7 +141,7 @@ extension SwiftJava.WrapJavaCommand { mutating func generateWrappers( config: Configuration, - dependentConfigs: [NamedDependentConfig], + dependencyConfigs: [NamedDependencyConfig], environment: JNIEnvironment ) throws { let translator = JavaTranslator( @@ -166,11 +166,11 @@ extension SwiftJava.WrapJavaCommand { log.info("Loaded Android API versions: \(apiVersions.stats())") } - // Note all of the dependent configurations. - for dependentConfig in dependentConfigs { + // Note all of the dependency configurations. + for dependencyConfig in dependencyConfigs { translator.addConfiguration( - dependentConfig.configuration, - forSwiftModule: dependentConfig.swiftModuleName + dependencyConfig.configuration, + forSwiftModule: dependencyConfig.swiftModuleName ) } diff --git a/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift b/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift index 51d48d7ca..a4632f8ba 100644 --- a/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift +++ b/Tests/JExtractSwiftTests/Asserts/TextAssertions.swift @@ -15,6 +15,8 @@ import CodePrinting import JExtractSwiftLib import SwiftJavaConfigurationShared +import SwiftParser +import SwiftSyntax import Testing import struct Foundation.CharacterSet @@ -33,6 +35,13 @@ func assertOutput( swiftModuleName: String = "SwiftModule", detectChunkByInitialLines _detectChunkByInitialLines: Int = 4, javaClassLookupTable: [String: String] = [:], + /// Map of dependency Swift module name to raw Swift source text. Used to seed + /// `translator.sourceDependencies` so cross-module type lookups resolve. + dependencySwiftSources: [String: String] = [:], + /// Map of Swift module name to Java package, mirroring what `--depends-on` + /// dependency configs would carry. Forwarded to the generator so cross-module + /// type references print with their fully-qualified Java name. + moduleJavaPackages: [String: String] = [:], expectedChunks: [String], notExpectedChunks: [String] = [], fileID: String = #fileID, @@ -43,7 +52,12 @@ func assertOutput( var config = config ?? Configuration() config.swiftModule = swiftModuleName let translator = Swift2JavaTranslator(config: config) - translator.dependenciesClasses = Array(javaClassLookupTable.keys) + translator.sourceDependencies.javaClasses = Array(javaClassLookupTable.keys) + for (depModule, depSource) in dependencySwiftSources { + let syntax = Parser.parse(source: depSource) + let input = SwiftJavaInputFile(syntax: syntax, path: "/fake/\(depModule).swift") + translator.sourceDependencies.swiftModuleInputs[depModule] = [input] + } try! translator.analyze(path: "/fake/Fake.swiftinterface", text: input) @@ -74,7 +88,7 @@ func assertOutput( swiftOutputDirectory: "/fake", javaOutputDirectory: "/fake", javaClassLookupTable: javaClassLookupTable, - moduleJavaPackages: [:] + moduleJavaPackages: moduleJavaPackages ) switch renderKind { diff --git a/Tests/JExtractSwiftTests/CrossModuleDependsOnTests.swift b/Tests/JExtractSwiftTests/CrossModuleDependsOnTests.swift new file mode 100644 index 000000000..fae9220fd --- /dev/null +++ b/Tests/JExtractSwiftTests/CrossModuleDependsOnTests.swift @@ -0,0 +1,167 @@ +//===----------------------------------------------------------------------===// +// +// 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 JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +/// Covers swift-java issue #715 / #746: when TargetA's API references a type +/// declared in TargetB, jextract should be able to resolve TargetB's type via +/// `--depends-on` (no hand-written `importedModuleStubs` required) and emit the +/// cross-module Java type using TargetB's package. +@Suite("Cross-module --depends-on resolution") +struct CrossModuleDependsOnTests { + + // ==== ---------------------------------------------------------------------- + // MARK: Top-level type from a dependency module + + @Test("JNI: top-level type from dependency module is resolved and printed with its Java package") + func jni_topLevelType_resolvedAndQualified() throws { + let dependencySource = """ + public struct DependencyPayload { + public let value: Int32 + public init(value: Int32) { + self.value = value + } + } + """ + let primarySource = """ + import DependencyLib + + public func consumePayload(_ p: DependencyPayload) -> Int32 { + return p.value + } + """ + + try assertOutput( + input: primarySource, + .jni, + .java, + swiftModuleName: "PrimaryLib", + dependencySwiftSources: ["DependencyLib": dependencySource], + moduleJavaPackages: ["DependencyLib": "com.example.dep"], + expectedChunks: [ + "consumePayload(com.example.dep.DependencyPayload p)" + ], + ) + } + + @Test("JNI: without dependency sources the function is dropped from the bindings") + func jni_topLevelType_droppedWithoutDependency() throws { + let primarySource = """ + import DependencyLib + + public func consumePayload(_ p: DependencyPayload) -> Int32 { + return p.value + } + """ + + try assertOutput( + input: primarySource, + .jni, + .java, + swiftModuleName: "PrimaryLib", + // Intentionally no dependencySwiftSources — simulates calling jextract + // without --depends-on for DependencyLib. The function should be skipped. + expectedChunks: [], + notExpectedChunks: ["consumePayload"], + ) + } + + // ==== ---------------------------------------------------------------------- + // MARK: Multiple dependency modules + + @Test("JNI: parameters from two distinct dependency modules both resolve") + func jni_twoDependentModules() throws { + let depA = """ + public struct InputBlob { + public let raw: Int64 + public init(raw: Int64) { self.raw = raw } + } + """ + let depB = """ + public struct OutputBlob { + public let raw: Int64 + public init(raw: Int64) { self.raw = raw } + } + """ + let primarySource = """ + import DepA + import DepB + + public func transform(_ input: InputBlob) -> OutputBlob { + return OutputBlob(raw: input.raw) + } + """ + + try assertOutput( + input: primarySource, + .jni, + .java, + swiftModuleName: "PrimaryLib", + dependencySwiftSources: [ + "DepA": depA, + "DepB": depB, + ], + moduleJavaPackages: [ + "DepA": "com.example.depa", + "DepB": "com.example.depb", + ], + expectedChunks: [ + "public static com.example.depb.OutputBlob transform(com.example.depa.InputBlob input," + ], + ) + } + + // ==== ---------------------------------------------------------------------- + // MARK: Nested types from a dependency module + // + // Exercises the case where a primary module's API references a nested type + // (e.g. `Outer.Inner`) declared inside an enum in another SwiftPM target. + // Without sourcing the dependency module's real declarations the only prior + // workaround was hand-writing an empty `public enum Outer {}` stub. + + @Test("JNI: nested type inside a dependency-module namespace enum is resolved") + func jni_nestedTypeInDependentModule() throws { + let dependencySource = """ + public enum Outer { + public struct Inner { + public let value: Int32 + public init(value: Int32) { + self.value = value + } + } + } + """ + let primarySource = """ + import DependencyLib + + public func consumeInner(_ inner: Outer.Inner) -> Int32 { + return inner.value + } + """ + + try assertOutput( + input: primarySource, + .jni, + .java, + swiftModuleName: "PrimaryLib", + dependencySwiftSources: ["DependencyLib": dependencySource], + moduleJavaPackages: ["DependencyLib": "com.example.dep"], + expectedChunks: [ + "consumeInner(com.example.dep.Outer.Inner inner)" + ], + ) + } +} diff --git a/Tests/JExtractSwiftTests/DependsOnGrammarTests.swift b/Tests/JExtractSwiftTests/DependsOnGrammarTests.swift new file mode 100644 index 000000000..7df11a53c --- /dev/null +++ b/Tests/JExtractSwiftTests/DependsOnGrammarTests.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// 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 JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +/// Parsing tests for the new `--depends-on` grammar (`Module=cfg[,sources...]`). +@Suite("--depends-on grammar") +struct DependsOnGrammarTests { + + @Test("Parsing rejects empty arguments") + func parsing_empty() { + #expect(throws: EmptyDependsOnArgumentError.self) { + _ = try parseDependsOnSyntax("") + } + } + + @Test("Module name is parsed from the LHS of '='") + func parsing_moduleName() throws { + // Real config files would normally exist; parseDependsOnSyntax falls back + // to an empty Configuration when readConfiguration returns nil. Use a path + // that's guaranteed not to exist so we exercise the fallback. + let parsed = try parseDependsOnSyntax("MyModule=/no/such/path/swift-java.config") + #expect(parsed.swiftModuleName == "MyModule") + } + + @Test("Explicit ',' suffix wins over inference") + func parsing_explicitSourcesSuffix() throws { + let parsed = try parseDependsOnSyntax( + "MyModule=/no/such/path/swift-java.config,/some/explicit/source/dir" + ) + #expect(parsed.swiftSourcePaths.map(\.path) == ["/some/explicit/source/dir"]) + } + + @Test("Multiple comma-separated sources are accepted (mirrors --input-swift)") + func parsing_multipleExplicitSources() throws { + let parsed = try parseDependsOnSyntax( + "MyModule=/no/such/swift-java.config,/a,/b,/c" + ) + #expect(parsed.swiftSourcePaths.map(\.path) == ["/a", "/b", "/c"]) + } +} diff --git a/Tests/JExtractSwiftTests/InputSwiftTests.swift b/Tests/JExtractSwiftTests/InputSwiftTests.swift index edbb7ac5b..aa53a1775 100644 --- a/Tests/JExtractSwiftTests/InputSwiftTests.swift +++ b/Tests/JExtractSwiftTests/InputSwiftTests.swift @@ -44,7 +44,7 @@ struct InputSwiftTests { config.outputSwiftDirectory = outSwiftURL.absoluteURL.path() config.outputJavaDirectory = outJavaURL.absoluteURL.path() - try SwiftToJava(config: config, dependentConfigs: []) + try SwiftToJava(config: config, dependencyConfigs: []) .run() } @@ -86,7 +86,7 @@ struct InputSwiftTests { config.outputSwiftDirectory = outSwiftURL.absoluteURL.path() config.outputJavaDirectory = outJavaURL.absoluteURL.path() - try SwiftToJava(config: config, dependentConfigs: []) + try SwiftToJava(config: config, dependencyConfigs: []) .run() } diff --git a/Tests/JExtractSwiftTests/JavaTypeAnnotationsTests.swift b/Tests/JExtractSwiftTests/JavaTypeAnnotationsTests.swift index 7a5165aad..031487368 100644 --- a/Tests/JExtractSwiftTests/JavaTypeAnnotationsTests.swift +++ b/Tests/JExtractSwiftTests/JavaTypeAnnotationsTests.swift @@ -29,6 +29,7 @@ struct JavaTypeAnnotationsTests { moduleName: "TestModule", [SwiftJavaInputFile(syntax: "" as SourceFileSyntax, path: "Fake.swift")], config: nil, + sourceDependencies: SourceDependencies(), log: Logger(label: "test", logLevel: .critical) ) self.knownTypes = SwiftKnownTypes(symbolTable: symbolTable) diff --git a/Tests/JExtractSwiftTests/SwiftSymbolTableTests.swift b/Tests/JExtractSwiftTests/SwiftSymbolTableTests.swift index 66a725f18..864457aa2 100644 --- a/Tests/JExtractSwiftTests/SwiftSymbolTableTests.swift +++ b/Tests/JExtractSwiftTests/SwiftSymbolTableTests.swift @@ -40,6 +40,7 @@ struct SwiftSymbolTableSuite { .init(syntax: sourceFile2, path: "Fake2.swift"), ], config: nil, + sourceDependencies: SourceDependencies(), log: Logger(label: "swift-java", logLevel: .critical), ) @@ -103,6 +104,7 @@ struct SwiftSymbolTableSuite { .init(syntax: sourceFile, path: "Fake.swift") ], config: nil, + sourceDependencies: SourceDependencies(), log: Logger(label: "swift-java", logLevel: .critical), )