diff --git a/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift b/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift index 9db552001..badab2e98 100644 --- a/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift +++ b/Sources/JExtractSwiftLib/Swift2JavaTranslator.swift @@ -78,7 +78,7 @@ extension Swift2JavaTranslator { AnalysisResult( importedTypes: self.importedTypes, importedGlobalVariables: self.importedGlobalVariables, - importedGlobalFuncs: self.importedGlobalFuncs + importedGlobalFuncs: self.importedGlobalFuncs, ) } @@ -117,12 +117,12 @@ extension Swift2JavaTranslator { visitor.visit( nominalDecl: dataDecl.syntax!.asNominal!, in: nil, - sourceFilePath: "Foundation/FAKE_FOUNDATION_DATA.swift" + sourceFilePath: "Foundation/FAKE_FOUNDATION_DATA.swift", ) visitor.visit( nominalDecl: dataProtocolDecl.syntax!.asNominal!, in: nil, - sourceFilePath: "Foundation/FAKE_FOUNDATION_DATAPROTOCOL.swift" + sourceFilePath: "Foundation/FAKE_FOUNDATION_DATAPROTOCOL.swift", ) } } @@ -133,7 +133,7 @@ extension Swift2JavaTranslator { visitor.visit( nominalDecl: dateDecl.syntax!.asNominal!, in: nil, - sourceFilePath: "Foundation/FAKE_FOUNDATION_DATE.swift" + sourceFilePath: "Foundation/FAKE_FOUNDATION_DATE.swift", ) } } @@ -145,7 +145,8 @@ extension Swift2JavaTranslator { let symbolTable = SwiftSymbolTable.setup( moduleName: self.swiftModuleName, inputs + [dependenciesSource], - log: self.log + config: self.config, + log: self.log, ) self.lookupContext = SwiftTypeLookupContext(symbolTable: symbolTable) } @@ -225,7 +226,7 @@ extension Swift2JavaTranslator { /// Try to resolve the given nominal declaration node into its imported representation. func importedNominalType( _ nominalNode: some DeclGroupSyntax & NamedDeclSyntax & WithModifiersSyntax & WithAttributesSyntax, - parent: ImportedNominalType? + parent: ImportedNominalType?, ) -> ImportedNominalType? { if !nominalNode.shouldExtract(config: config, log: log, in: parent) { return nil @@ -249,9 +250,12 @@ extension Swift2JavaTranslator { } // Whether to import this extension? - guard swiftNominalDecl.moduleName == self.swiftModuleName else { + let isFromThisModule = swiftNominalDecl.moduleName == self.swiftModuleName + let isFromStubbedModule = config.hasImportedModuleStub(moduleOfNominal: swiftNominalDecl.moduleName) + guard isFromThisModule || isFromStubbedModule else { return nil } + guard swiftNominalDecl.syntax!.shouldExtract(config: config, log: log, in: nil) else { return nil } @@ -266,12 +270,6 @@ extension Swift2JavaTranslator { return alreadyImported } - // Apply type-name filters (patterns with `.`) - guard shouldJExtractType(qualifiedName: fullName, config: config) else { - log.info("Skipping type (filtered out): \(fullName)") - return nil - } - let importedNominal = try? ImportedNominalType(swiftNominal: nominal, lookupContext: lookupContext) importedTypes[fullName] = importedNominal @@ -279,7 +277,7 @@ extension Swift2JavaTranslator { } } -// ==== ---------------------------------------------------------------------------------------------------------------- +// ==== ----------------------------------------------------------------------- // MARK: Errors public struct Swift2JavaTranslatorError: Error { diff --git a/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift b/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift index abac0920a..d1a5d9e01 100644 --- a/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift +++ b/Sources/JExtractSwiftLib/SwiftTypes/SwiftSymbolTable.swift @@ -13,6 +13,8 @@ //===----------------------------------------------------------------------===// import CodePrinting +import SwiftJavaConfigurationShared +import SwiftParser import SwiftSyntax package protocol SwiftSymbolTableProtocol { @@ -66,7 +68,8 @@ extension SwiftSymbolTable { package static func setup( moduleName: String, _ inputFiles: some Collection, - log: Logger + config: Configuration?, + log: Logger, ) -> SwiftSymbolTable { // Prepare imported modules. @@ -90,12 +93,36 @@ extension SwiftSymbolTable { } } + // 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. + if let stubs = config?.importedModuleStubs { + for (stubModuleName, declarations) in stubs { + if importedModules[stubModuleName] == nil { + let source = declarations.joined(separator: "\n") + let sourceFile = Parser.parse(source: source) + var stubBuilder = SwiftParsedModuleSymbolTableBuilder( + moduleName: stubModuleName, + importedModules: ["Swift": importedModules["Swift"]!], + ) + stubBuilder.handle(sourceFile: sourceFile, sourceFilePath: "\(stubModuleName)_stub.swift") + let stubModule = stubBuilder.finalize() + importedModules[stubModuleName] = stubModule + log.info("Loaded module stub for '\(stubModuleName)' with \(declarations.count) declaration(s), top-level types: \(stubModule.topLevelTypes.keys.sorted())") + } else { + log.info("Module '\(stubModuleName)' already known, skipping stub") + } + } + } else { + log.debug("No importedModuleStubs in config") + } + // FIXME: Support granular lookup context (file, type context). var builder = SwiftParsedModuleSymbolTableBuilder( moduleName: moduleName, importedModules: importedModules, - log: log + log: log, ) // First, register top-level and nested nominal types to the symbol table. for sourceFile in inputFiles { diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index b11d4fdd5..b660c4594 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -87,6 +87,44 @@ public struct Configuration: Codable { /// Same pattern syntax as swiftFilterInclude public var swiftFilterExclude: [String]? + /// Stub type declarations for imported modules whose source is not available + /// to the jextract tool. Keyed by module name, values are arrays of Swift + /// declaration strings that will be parsed as if they belonged to that module. + /// + /// Example: + /// ```json + /// { + /// "importedModuleStubs": { + /// "ExternalModule": [ + /// "public enum Outer {}", + /// "public struct Config {}" + /// ] + /// } + /// } + /// ``` + public var importedModuleStubs: [String: [String]]? + + /// Whether the given module name has stub declarations configured + public func hasImportedModuleStub(moduleOfNominal moduleName: String) -> Bool { + importedModuleStubs?.keys.contains(moduleName) ?? false + } + + /// Monomorphization entries for generic types, mapping a qualified Swift type + /// name to a concrete specialization with a custom Java-facing name. + /// + /// Example: + /// ```json + /// { + /// "monomorphize": { + /// "Tank": { + /// "javaName": "FishTank", + /// "typeArgs": {"Element": "Fish"} + /// } + /// } + /// } + /// ``` + public var monomorphize: [String: MonomorphizeEntry]? + // ==== wrap-java --------------------------------------------------------- /// The Java class path that should be passed along to the swift-java tool. @@ -220,7 +258,7 @@ public enum MavenRepositoryDescriptor: Hashable, Codable { throw DecodingError.dataCorruptedError( forKey: .type, in: container, - debugDescription: "Unknown repository type: '\(type)'. Supported: maven, mavenCentral, mavenLocal, google" + debugDescription: "Unknown repository type: '\(type)'. Supported: maven, mavenCentral, mavenLocal, google", ) } } @@ -302,7 +340,7 @@ public func readConfiguration( string: String, configPath: URL?, file: String = #fileID, - line: UInt = #line + line: UInt = #line, ) throws -> Configuration? { guard let configData = string.data(using: .utf8) else { return nil @@ -319,7 +357,7 @@ public func readConfiguration( error: error, text: string, file: file, - line: line + line: line, ) } } @@ -426,6 +464,23 @@ public struct ConfigurationError: Error { } } +// ==== ----------------------------------------------------------------------- +// MARK: MonomorphizeEntry + +/// Configuration entry for monomorphizing a generic type into a concrete Java class +public struct MonomorphizeEntry: Codable, Sendable { + /// Mapping from generic parameter name to concrete type (e.g. {"T": "Fish"}) + public var typeArgs: [String: String] + + /// The Java-facing class name (e.g. "FishTank") + public var javaName: String + + public init(typeArgs: [String: String], javaName: String) { + self.typeArgs = typeArgs + self.javaName = javaName + } +} + public enum LogLevel: String, ExpressibleByStringLiteral, Codable, Hashable { case trace = "trace" case debug = "debug" diff --git a/Sources/SwiftJavaMacros/JavaExportMacro.swift b/Sources/SwiftJavaMacros/JavaExportMacro.swift new file mode 100644 index 000000000..c26b3526e --- /dev/null +++ b/Sources/SwiftJavaMacros/JavaExportMacro.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax +import SwiftSyntaxMacros + +/// Marker macro for jextract: forces a Swift declaration to be exported to Java. +/// +/// When applied to a typealias, registers a monomorphization entry for generic types. +/// When applied to a nominal type, force-includes it for export regardless of filters. +/// +/// This macro produces no code — it is purely a marker read by the jextract tool. +package enum JavaExportMacro {} + +extension JavaExportMacro: PeerMacro { + package static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext, + ) throws -> [DeclSyntax] { + // Marker-only macro — no code generation + [] + } +} diff --git a/Tests/JExtractSwiftTests/FFM/FFMImportedModuleStubsTests.swift b/Tests/JExtractSwiftTests/FFM/FFMImportedModuleStubsTests.swift new file mode 100644 index 000000000..836e76a88 --- /dev/null +++ b/Tests/JExtractSwiftTests/FFM/FFMImportedModuleStubsTests.swift @@ -0,0 +1,206 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import JExtractSwiftLib +import SwiftJavaConfigurationShared +import Testing + +@Suite +struct FFMImportedModuleStubsTests { + + // The "main" module source imports an external module and uses its types + let source = """ + import ExternalModule + + public func makeConfig() -> ExternalModule.Config + + public func takeConfig(_ config: ExternalModule.Config) + + public struct MyStruct { + public func useConfig(_ config: ExternalModule.Config) -> ExternalModule.Config + } + """ + + var stubConfig: Configuration { + var config = Configuration() + config.importedModuleStubs = [ + "ExternalModule": [ + "public struct Config {}" + ] + ] + return config + } + + // ==== ----------------------------------------------------------------------- + // MARK: Java bindings + + @Test("Return type from stubbed module generates correct Java binding") + func returnStubbedType_javaBindings() throws { + try assertOutput( + input: source, + config: stubConfig, + .ffm, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static Config makeConfig(" + ], + ) + } + + @Test("Parameter from stubbed module generates correct Java binding") + func takeStubbedType_javaBindings() throws { + try assertOutput( + input: source, + config: stubConfig, + .ffm, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static void takeConfig(" + ], + ) + } + + @Test("Member method using stubbed type generates correct Java binding") + func memberUsingStubbedType_javaBindings() throws { + try assertOutput( + input: source, + config: stubConfig, + .ffm, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public Config useConfig(" + ], + ) + } + + // ==== ----------------------------------------------------------------------- + // MARK: Swift thunks + + @Test("Return type from stubbed module generates correct Swift thunk") + func returnStubbedType_swiftThunks() throws { + try assertOutput( + input: source, + config: stubConfig, + .ffm, + .swift, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + @_cdecl("swiftjava_SwiftModule_makeConfig") + public func swiftjava_SwiftModule_makeConfig(_ _result: UnsafeMutableRawPointer) { + """ + ], + ) + } + + @Test("Parameter from stubbed module generates correct Swift thunk") + func takeStubbedType_swiftThunks() throws { + try assertOutput( + input: source, + config: stubConfig, + .ffm, + .swift, + detectChunkByInitialLines: 2, + expectedChunks: [ + """ + @_cdecl("swiftjava_SwiftModule_takeConfig__") + public func swiftjava_SwiftModule_takeConfig__(_ config: UnsafeRawPointer) { + """ + ], + ) + } + + // ==== ----------------------------------------------------------------------- + // MARK: Without stubs, types should not resolve + + @Test("Without stubs, external types are not resolved") + func withoutStubs_typesNotResolved() throws { + // Without importedModuleStubs, ExternalModule.Config is unknown + // and the functions using it should not appear in the output + try assertOutput( + input: source, + .ffm, + .java, + expectedChunks: [], + notExpectedChunks: [ + "makeConfig", + "takeConfig", + ], + ) + } + + // ==== ----------------------------------------------------------------------- + // MARK: Nested types in stubs + + @Test("Nested types in stubbed module") + func nestedStubbedType_javaBindings() throws { + let nestedSource = """ + import Networking + + public func getEndpoint() -> Networking.API.Endpoint + """ + + var config = Configuration() + config.importedModuleStubs = [ + "Networking": [ + "public enum API {}", + "extension API { public struct Endpoint {} }", + ] + ] + + try assertOutput( + input: nestedSource, + config: config, + .ffm, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static API.Endpoint getEndpoint(" + ], + ) + } + + // ==== ----------------------------------------------------------------------- + // MARK: Multiple stubbed modules + + @Test("Multiple stubbed modules resolve correctly") + func multipleModules_javaBindings() throws { + let multiSource = """ + import ModuleA + import ModuleB + + public func convert(_ a: ModuleA.Input) -> ModuleB.Output + """ + + var config = Configuration() + config.importedModuleStubs = [ + "ModuleA": ["public struct Input {}"], + "ModuleB": ["public struct Output {}"], + ] + + try assertOutput( + input: multiSource, + config: config, + .ffm, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public static Output convert(" + ], + ) + } +} diff --git a/Tests/JExtractSwiftTests/SwiftSymbolTableTests.swift b/Tests/JExtractSwiftTests/SwiftSymbolTableTests.swift index d11d5aa73..bdf00de18 100644 --- a/Tests/JExtractSwiftTests/SwiftSymbolTableTests.swift +++ b/Tests/JExtractSwiftTests/SwiftSymbolTableTests.swift @@ -39,7 +39,8 @@ struct SwiftSymbolTableSuite { .init(syntax: sourceFile1, path: "Fake.swift"), .init(syntax: sourceFile2, path: "Fake2.swift"), ], - log: Logger(label: "swift-java", logLevel: .critical) + config: nil, + log: Logger(label: "swift-java", logLevel: .critical), ) let x = try #require(symbolTable.lookupType("X", parent: nil)) @@ -68,7 +69,7 @@ struct SwiftSymbolTableSuite { expectedChunks: [ "public static MyValue fullyQualifiedType(", "public static Data fullyQualifiedType2(", - ] + ], ) } @@ -88,7 +89,7 @@ struct SwiftSymbolTableSuite { detectChunkByInitialLines: 1, expectedChunks: [ "public static MyModule.MyValue fullyQualifiedType(" - ] + ], ) } }