diff --git a/Package.swift b/Package.swift index 14bd36aea..6fcb0bcb8 100644 --- a/Package.swift +++ b/Package.swift @@ -59,6 +59,12 @@ let package = Package( targets: ["SwiftJavaConfigurationShared"] ), + // Marker macros such as `@JavaExport` that steer jextract, without the JNI runtime + .library( + name: "SwiftJavaAnnotations", + targets: ["SwiftJavaAnnotations"] + ), + .library( name: "SwiftExtractConfigurationShared", targets: ["SwiftExtractConfigurationShared"] @@ -196,10 +202,17 @@ let package = Package( .swiftLanguageMode(.v5) ] ), + .target( + name: "SwiftJavaAnnotations", + dependencies: [ + "SwiftJavaMacros" + ] + ), .target( name: "SwiftJava", dependencies: [ .product(name: "SwiftJavaJNICore", package: "swift-java-jni-core"), + "SwiftJavaAnnotations", "SwiftJavaMacros", ], exclude: ["swift-java.config"], diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ExportControl.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ExportControl.swift new file mode 100644 index 000000000..4d7824de3 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ExportControl.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 SwiftJava + +/// A public type whose Swift-only helpers stay out of the Kotlin API. Without `@JavaExport(false)`, +/// the tuple and `Any` members would be skipped declarations and fail this sample's build. +public struct SessionInfo { + public var userName: String + + public init(userName: String) { + self.userName = userName + } + + @JavaExport(false) + public func debugPairs() -> [(Int64, String)] { + [(1, userName)] + } + + /// Prefix operators have no Java mapping and are left out. + public prefix static func - (info: SessionInfo) -> SessionInfo { + SessionInfo(userName: String(info.userName.reversed())) + } +} + +@JavaExport(false) +extension SessionInfo { + public var debugDump: [String: Any] { + ["userName": userName] + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ExportControlTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ExportControlTest.kt new file mode 100644 index 000000000..aa083c519 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ExportControlTest.kt @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 +// +//===----------------------------------------------------------------------===// + +package com.example.kotlin + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// `@JavaExport(false)` keeps Swift-only members out of the Kotlin API without failing the build. +class ExportControlTest { + @Test + fun exportedMembersStay() { + assertEquals("ann", SessionInfo.init("ann").userName) + } + + @Test + fun optedOutMembersAreMissing() { + val names = SessionInfo::class.java.methods.map { it.name } + assertTrue("debugPairs" !in names, "$names") + assertTrue("getDebugDump" !in names, "$names") + } +} diff --git a/Sources/JExtractSwiftLib/JavaExtractDecider.swift b/Sources/JExtractSwiftLib/JavaExtractDecider.swift index 682002e52..4713bfdd1 100644 --- a/Sources/JExtractSwiftLib/JavaExtractDecider.swift +++ b/Sources/JExtractSwiftLib/JavaExtractDecider.swift @@ -18,12 +18,20 @@ import SwiftSyntax public func makeSwiftJavaAnalyzer( config: Configuration, + dependencyConfigs: [DependencyConfig] = [], diagnosticsSink: (any SwiftExtractDiagnosticsSink)? = nil ) -> SwiftAnalyzer { SwiftAnalyzer( config: config, extractDecider: JavaExtractDecider( accessLevel: config.effectiveMinimumInputAccessLevelMode, + exportMode: config.effectiveExportMode, + swiftModuleName: config.swiftModule, + dependencyExportModes: dependencyConfigs.reduce(into: [:]) { modes, dependency in + if let moduleName = dependency.swiftModuleName { + modes[moduleName] = dependency.configuration.effectiveExportMode + } + }, logLevel: config.logLevel ?? .info ), diagnosticsSink: diagnosticsSink @@ -37,6 +45,11 @@ public func makeSwiftJavaAnalyzer( /// - Skips initializers of unspecialized generic types /// - Force-extracts decls annotated `@JavaExport` even if they would /// otherwise be filtered by access level +/// - Skips decls annotated `@JavaExport(false)`, or inside a type or extension +/// annotated `@JavaExport(false)` +/// - With the `annotated` export mode, extracts only decls annotated +/// `@JavaExport`, inside a type or extension annotated `@JavaExport`, or +/// types with such a member /// - Skips Swift wrappers of Java types (`@JavaClass`, `@JavaInterface`, /// `@JavaField`, `@JavaStaticField`, `@JavaMethod`, `@JavaStaticMethod`, /// `@JavaImplementation`) since those are bridged the other way @@ -44,10 +57,24 @@ public func makeSwiftJavaAnalyzer( /// operator-overload syntax, so the generator can't render them public struct JavaExtractDecider: ExtractDecider { public let accessLevel: AccessLevelMode + public let exportMode: JExtractExportMode + /// The module being extracted, whose types follow `exportMode` + public let swiftModuleName: String? + /// The export mode of each dependency module, from its `swift-java.config` + public let dependencyExportModes: [String: JExtractExportMode] let log: Logger - public init(accessLevel: AccessLevelMode = .default, logLevel: LogLevel = .info) { + public init( + accessLevel: AccessLevelMode = .default, + exportMode: JExtractExportMode = .default, + swiftModuleName: String? = nil, + dependencyExportModes: [String: JExtractExportMode] = [:], + logLevel: LogLevel = .info + ) { self.accessLevel = accessLevel + self.exportMode = exportMode + self.swiftModuleName = swiftModuleName + self.dependencyExportModes = dependencyExportModes self.log = Logger(label: "JavaExtractDecider", logLevel: logLevel) } @@ -66,33 +93,74 @@ public struct JavaExtractDecider: ExtractDecider { return false } - let attrs = decl.asProtocol((any WithAttributesSyntax).self)?.attributes - if attrs?.contains(where: { $0.isJavaExport }) == true { + switch decl.javaExportMarker { + case true?: return true + case false?: + log.trace("Skip '\(decl.qualifiedNameForDebug)': annotated @JavaExport(false)") + return false + case nil: + break } + let attrs = decl.asProtocol((any WithAttributesSyntax).self)?.attributes if attrs?.contains(where: { $0.isJavaKitMacro }) == true { log.trace("Skip '\(decl.qualifiedNameForDebug)': swift-java macro-wrapped Java type") return false } - // Swift operators have no Java mapping - if let fn = decl.as(FunctionDeclSyntax.self) { - switch fn.name.tokenKind { - case .prefixOperator, .postfixOperator: - log.trace("Skip '\(decl.qualifiedNameForDebug)': operators are not supported on Java") - return false - default: - break - } + // Swift prefix and postfix operators have no Java mapping + if let fn = decl.as(FunctionDeclSyntax.self), + fn.modifiers.contains(where: { $0.name.tokenKind == .keyword(.prefix) || $0.name.tokenKind == .keyword(.postfix) }) + { + log.trace("Skip '\(decl.qualifiedNameForDebug)': operators are not supported on Java") + return false + } + + let inheritedMarker = decl.inheritedJavaExportMarker + if inheritedMarker == false { + log.trace("Skip '\(decl.qualifiedNameForDebug)': inside a declaration annotated @JavaExport(false)") + return false } guard let mod = decl.asProtocol((any WithModifiersSyntax).self) else { return false } - let ok = mod.isAtLeast(accessLevel, in: parent) - if !ok { + guard mod.isAtLeast(accessLevel, in: parent) else { log.trace("Skip '\(decl.qualifiedNameForDebug)': not at least \(accessLevel)") + return false + } + + if exportMode == .annotated, inheritedMarker != true, !decl.containsJavaExportedMember, + !decl.isFromKnownModuleSource + { + log.trace("Skip '\(decl.qualifiedNameForDebug)': not annotated @JavaExport (exportMode: annotated)") + return false + } + return true + } + + public func isExported(_ type: SwiftNominalTypeDeclaration) -> Bool { + let mode: JExtractExportMode + if type.moduleName == swiftModuleName { + mode = exportMode + } else if let dependencyMode = dependencyExportModes[type.moduleName] { + mode = dependencyMode + } else { + // A known module such as Foundation, or a module that isn't extracted + return true + } + switch type.syntax.javaExportMarker ?? type.syntax.inheritedJavaExportMarker { + case true?: return true + case false?: return false + case nil: return mode == .all || type.syntax.containsJavaExportedMember + } + } + + public func mayExtractMembers(of extensionDecl: ExtensionDeclSyntax) -> Bool { + switch extensionDecl.javaExportMarker ?? extensionDecl.inheritedJavaExportMarker { + case true?: true + case false?: false + case nil: exportMode == .all || extensionDecl.containsJavaExportedMember } - return ok } } diff --git a/Sources/JExtractSwiftLib/Swift2Java.swift b/Sources/JExtractSwiftLib/Swift2Java.swift index 1bd29a7e9..5a73e5c09 100644 --- a/Sources/JExtractSwiftLib/Swift2Java.swift +++ b/Sources/JExtractSwiftLib/Swift2Java.swift @@ -38,7 +38,11 @@ public struct SwiftToJava { let javaPackage = try validateJavaPackage(config.javaPackage) let skippedDuringAnalysis = SkippedDeclarationCollector() - let translator = makeSwiftJavaAnalyzer(config: config, diagnosticsSink: skippedDuringAnalysis) + let translator = makeSwiftJavaAnalyzer( + config: config, + dependencyConfigs: dependencyConfigs, + diagnosticsSink: skippedDuringAnalysis + ) let log = translator.log guard let inputSwift = config.inputSwiftDirectory else { diff --git a/Sources/JExtractSwiftLib/SwiftSyntax+Java.swift b/Sources/JExtractSwiftLib/SwiftSyntax+Java.swift index c035bae01..3c81c8bac 100644 --- a/Sources/JExtractSwiftLib/SwiftSyntax+Java.swift +++ b/Sources/JExtractSwiftLib/SwiftSyntax+Java.swift @@ -49,9 +49,47 @@ extension AttributeListSyntax.Element { /// Whether this is a `@JavaExport` attribute (used on typealiases for specialization, /// or on struct/class/enum to force-include them even when excluded by filters) package var isJavaExport: Bool { - guard case let .attribute(attr) = self else { return false } - guard let attrName = attr.attributeName.as(IdentifierTypeSyntax.self)?.name.text else { return false } - return attrName == "JavaExport" + javaExportValue != nil + } + + /// `true` for `@JavaExport`, `false` for `@JavaExport(false)`, `nil` for any other attribute + package var javaExportValue: Bool? { + guard case let .attribute(attr) = self else { return nil } + guard attr.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "JavaExport" else { return nil } + guard case .argumentList(let arguments) = attr.arguments, + let literal = arguments.first?.expression.as(BooleanLiteralExprSyntax.self) + else { + return true + } + return literal.literal.tokenKind == .keyword(.true) + } +} + +extension SyntaxProtocol { + /// The value of this declaration's own `@JavaExport` attribute, if it has one + package var javaExportMarker: Bool? { + guard let attributes = self.asProtocol((any WithAttributesSyntax).self)?.attributes else { return nil } + return attributes.lazy.compactMap(\.javaExportValue).first + } + + /// The `@JavaExport` value of the nearest enclosing type or extension that has one + package var inheritedJavaExportMarker: Bool? { + var node = self.parent + while let current = node { + if current.is(DeclSyntax.self), let marker = current.javaExportMarker { + return marker + } + node = current.parent + } + return nil + } + + /// Whether a member of this type, at any depth, is marked `@JavaExport` + package var containsJavaExportedMember: Bool { + guard let group = self.asProtocol((any DeclGroupSyntax).self) else { return false } + return group.memberBlock.members.contains { item in + item.decl.javaExportMarker == true || item.decl.containsJavaExportedMember + } } } diff --git a/Sources/SwiftExtract/ExtractDecider.swift b/Sources/SwiftExtract/ExtractDecider.swift index 2e3a02302..3a543673e 100644 --- a/Sources/SwiftExtract/ExtractDecider.swift +++ b/Sources/SwiftExtract/ExtractDecider.swift @@ -38,6 +38,24 @@ public protocol ExtractDecider { decl: DeclSyntax, in parent: ExtractedNominalType? ) -> Bool + + /// Whether any member of `extensionDecl` could be extracted. When this is `false`, an extension + /// whose extended type doesn't resolve is ignored instead of being reported as skipped. + func mayExtractMembers(of extensionDecl: ExtensionDeclSyntax) -> Bool + + /// Whether `type`, from this module or a dependency, is exported. A declaration whose signature uses a + /// type that isn't exported is skipped, since the generated code would refer to a type that doesn't exist. + func isExported(_ type: SwiftNominalTypeDeclaration) -> Bool +} + +extension ExtractDecider { + public func mayExtractMembers(of extensionDecl: ExtensionDeclSyntax) -> Bool { + true + } + + public func isExported(_ type: SwiftNominalTypeDeclaration) -> Bool { + true + } } /// Minimal `ExtractDecider` that enforces only the configured access-level diff --git a/Sources/SwiftExtract/SwiftAnalysisVisitor.swift b/Sources/SwiftExtract/SwiftAnalysisVisitor.swift index 8f9d003cc..a7e898a2d 100644 --- a/Sources/SwiftExtract/SwiftAnalysisVisitor.swift +++ b/Sources/SwiftExtract/SwiftAnalysisVisitor.swift @@ -146,6 +146,10 @@ final class SwiftAnalysisVisitor { return case .unresolved: + guard analyzer.extractDecider.mayExtractMembers(of: node) else { + log.trace("Skip 'extension \(node.extendedType.trimmedDescription)': no member can be extracted") + return + } self.reportSkipped( node, name: "extension \(node.extendedType.trimmedDescription)", @@ -354,6 +358,7 @@ final class SwiftAnalysisVisitor { enclosingType: typeContext?.swiftType, lookupContext: analyzer.lookupContext, ) + try checkTypesAreExported(signature) } catch { self.reportSkipped( node, @@ -411,6 +416,7 @@ final class SwiftAnalysisVisitor { enclosingType: typeContext.swiftType, lookupContext: analyzer.lookupContext, ) + try checkTypesAreExported(signature) let caseName = caseElement.name.text.unescapedSwiftName let caseFunction = ExtractedFunc( @@ -514,6 +520,7 @@ final class SwiftAnalysisVisitor { enclosingType: typeContext.swiftType, lookupContext: analyzer.lookupContext, ) + try checkTypesAreExported(signature) } catch { self.reportSkipped( node, @@ -632,6 +639,7 @@ final class SwiftAnalysisVisitor { log.warning("Not supported declaration type \(node.kind) while calling importAccessor!") return } + try checkTypesAreExported(signature) let extracted = ExtractedFunc( module: analyzer.swiftModuleName, @@ -893,6 +901,51 @@ final class SwiftAnalysisVisitor { return "\(message). \(hint)" } + /// A declaration uses a type that isn't exported, so the generated code would refer to a type that doesn't exist + struct UnexportedTypeError: Error, CustomStringConvertible { + let typeName: String + + var description: String { + "uses '\(typeName)', which is not exported" + } + } + + /// Throws `UnexportedTypeError` if a parameter or the result of `signature` uses a type that isn't exported + func checkTypesAreExported(_ signature: SwiftFunctionSignature) throws { + func check(_ type: SwiftType) throws { + switch type { + case .nominal(let nominal): + for argument in nominal.genericArguments { + try check(argument) + } + guard analyzer.extractDecider.isExported(nominal.nominalTypeDecl) else { + throw UnexportedTypeError(typeName: nominal.nominalTypeDecl.qualifiedName) + } + case .tuple(let elements): + for element in elements { + try check(element.type) + } + case .function(let function): + for parameter in function.parameters { + try check(parameter.type) + } + try check(function.resultType) + case .metatype(let type), .existential(let type), .opaque(let type), .inlineArray(_, let type): + try check(type) + case .composite(let types): + for type in types { + try check(type) + } + case .genericParameter: + break + } + } + for parameter in signature.parameters { + try check(parameter.type) + } + try check(signature.result.type) + } + /// Log a skipped declaration (as before) and report it to the configured /// diagnostics sink, if any. func reportSkipped( diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift index 72739cf60..150306878 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftKnownModules.swift @@ -41,6 +41,15 @@ public enum SwiftKnownModule: String { } } +extension SyntaxProtocol { + /// Whether this node is part of the source SwiftExtract uses to describe a known module, such as + /// the `Foundation.URL` it extracts when a module uses `URL`, rather than of the module's own source. + public var isFromKnownModuleSource: Bool { + let root = self.root.id + return root == swiftSourceFile.id || root == foundationEssentialsSourceFile.id + } +} + private var swiftSymbolTable: SwiftModuleSymbolTable { var builder = SwiftParsedModuleSymbolTableBuilder(moduleName: "Swift", importedModules: [:]) builder.handle(sourceFile: swiftSourceFile, sourceFilePath: "SwiftStdlib.swift") // FIXME: missing path here diff --git a/Sources/SwiftJava/CSwiftJavaJNI+Reexport.swift b/Sources/SwiftJava/CSwiftJavaJNI+Reexport.swift index 88d073d38..c36e0e0f7 100644 --- a/Sources/SwiftJava/CSwiftJavaJNI+Reexport.swift +++ b/Sources/SwiftJava/CSwiftJavaJNI+Reexport.swift @@ -12,4 +12,5 @@ // //===----------------------------------------------------------------------===// +@_exported import SwiftJavaAnnotations @_exported import SwiftJavaJNICore diff --git a/Sources/SwiftJava/Macros.swift b/Sources/SwiftJava/Macros.swift index 20a911233..a56fb835b 100644 --- a/Sources/SwiftJava/Macros.swift +++ b/Sources/SwiftJava/Macros.swift @@ -232,15 +232,3 @@ public macro JavaStaticMethod(_ javaMethodName: String? = nil) = @attached(peer) public macro JavaImplementation(_ fullClassName: String) = #externalMacro(module: "SwiftJavaMacros", type: "JavaImplementationMacro") - -/// Marker macro that forces a Swift declaration to be exported to Java via jextract. -/// -/// When applied to a `typealias`, it registers a specialization entry for generic types: -/// ```swift -/// @JavaExport public typealias FishTank = Tank -/// ``` -/// This tells jextract to generate a concrete `FishTank` Java class for `Tank`, -/// even if `Tank` was included in the `filterExclude` configuration. -@attached(peer) -public macro JavaExport() = - #externalMacro(module: "SwiftJavaMacros", type: "JavaExportMacro") diff --git a/Sources/SwiftJavaAnnotations/JavaExport.swift b/Sources/SwiftJavaAnnotations/JavaExport.swift new file mode 100644 index 000000000..031407958 --- /dev/null +++ b/Sources/SwiftJavaAnnotations/JavaExport.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 +// +//===----------------------------------------------------------------------===// + +/// Marker macro that controls whether jextract exports a Swift declaration to Java. +/// +/// `@JavaExport` forces a declaration to be exported. When applied to a `typealias`, +/// it registers a specialization entry for generic types: +/// ```swift +/// @JavaExport public typealias FishTank = Tank +/// ``` +/// This tells jextract to generate a concrete `FishTank` Java class for `Tank`, +/// even if `Tank` was included in the `filterExclude` configuration. +/// +/// `@JavaExport(false)` keeps a declaration out of the Java bindings. On a type or an +/// extension, it also applies to their members and nested types: +/// ```swift +/// public struct Session { +/// public var token: String +/// @JavaExport(false) public var debugDump: [String: Any] +/// } +/// ``` +/// +/// With `"exportMode": "annotated"` in `swift-java.config`, jextract exports only declarations +/// marked `@JavaExport`, together with the members of a marked type or extension. +/// +/// This module has no JNI dependency, so code that is also built for platforms +/// without Java can use the marker. +@attached(peer) +public macro JavaExport(_ isExported: Bool = true) = + #externalMacro(module: "SwiftJavaMacros", type: "JavaExportMacro") diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index f711b6e6d..df268bb67 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -181,6 +181,14 @@ public struct Configuration: Codable { enableJavaCallbacks ?? false } + /// Which declarations to export: `all` (default) exports every declaration that passes the access level + /// and filters, except those opted out with `@JavaExport(false)`. `annotated` exports only declarations marked + /// `@JavaExport`, including the members of a marked type or extension. + public var exportMode: JExtractExportMode? + public var effectiveExportMode: JExtractExportMode { + exportMode ?? .default + } + /// If true, JExtract fails after generating when it skipped any declaration it could not /// extract or translate, and lists each one. Skipped declarations are always reported as warnings. public var failOnSkippedDeclarations: Bool? diff --git a/Sources/SwiftJavaConfigurationShared/JExtract/JExtractExportMode.swift b/Sources/SwiftJavaConfigurationShared/JExtract/JExtractExportMode.swift new file mode 100644 index 000000000..dafeec36a --- /dev/null +++ b/Sources/SwiftJavaConfigurationShared/JExtract/JExtractExportMode.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 +// +//===----------------------------------------------------------------------===// + +/// Which declarations jextract exports to Java. +public enum JExtractExportMode: String, Codable { + /// Export every declaration that passes the access level and filters, except those marked + /// `@JavaExport(false)` or inside a type or extension marked `@JavaExport(false)`. + case all + + /// Export only declarations marked `@JavaExport`, or inside a type or extension marked `@JavaExport`. + /// A type that isn't marked is still exported when one of its members is. + case annotated + + public static var `default`: Self { + .all + } +} diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md index c3a415045..0cdfe265d 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -246,6 +246,22 @@ only supported in `jni` mode. --- +#### exportMode + +- **Type:** `JExtractExportMode?` +- **Default:** `all` + +Which declarations to export: `all` (default) exports every declaration that passes the access level +and filters, except those opted out with `@JavaExport(false)`. `annotated` exports only declarations marked +`@JavaExport`, including the members of a marked type or extension. + +**Values:** + +- `all` - Export every declaration that passes the access level and filters, except those marked `@JavaExport(false)` or inside a type or extension marked `@JavaExport(false)`. +- `annotated` - Export only declarations marked `@JavaExport`, or inside a type or extension marked `@JavaExport`. A type that isn't marked is still exported when one of its members is. + +--- + #### failOnSkippedDeclarations - **Type:** `Bool?` diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaJextract.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaJextract.md index 256028084..b5ae03d3a 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaJextract.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaJextract.md @@ -67,6 +67,33 @@ public final class SomeModule ... { } ``` +### Choosing what to export + +`@JavaExport(false)`, from the `SwiftJavaAnnotations` module (also re-exported by `SwiftJava`), keeps a +public declaration out of the Java bindings. On a type or an extension it applies to all their members: + +```swift +import SwiftJavaAnnotations + +public struct Session { + public var token: String + @JavaExport(false) public var debugDump: [String: Any] +} + +@JavaExport(false) +extension Session { + public func swiftOnlyHelper() -> (Int, String) { ... } +} +``` + +With `"exportMode": "annotated"` in `swift-java.config`, jextract does the opposite: it exports only +declarations marked `@JavaExport`, and the members of a type or extension marked `@JavaExport`. A type +that isn't marked is still exported, with only its marked members, when one of its members is marked. +An extension's members are exported only when the extended type is exported too. + +Declarations that aren't exported are never translated, so they can't produce skipped-declaration +warnings or fail a build that uses `failOnSkippedDeclarations`. + ## See Also - diff --git a/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift b/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift new file mode 100644 index 000000000..67717bcc8 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift @@ -0,0 +1,229 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 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 CodePrinting +import JExtractSwiftLib +import SwiftExtract +import SwiftJavaConfigurationShared +import SwiftParser +import Testing + +/// `@JavaExport(false)` opts declarations out, and `exportMode: annotated` exports only marked ones. +@Suite +struct JNIExportControlTests { + let optOutSource = + """ + public struct Session { + public var token: String + @JavaExport(false) public var secret: String + @JavaExport(false) public func pairs() -> [(Int64, String)] { [] } + } + @JavaExport(false) + public struct Hidden { + public func pairs() -> [(Int64, String)] { [] } + } + @JavaExport(false) + extension Session { + public func fromExtension() -> [(Int64, String)] { [] } + } + public struct Interval { + public var seconds: Int64 + public prefix static func - (lhs: Interval) -> Interval { lhs } + } + @JavaExport(false) + extension UnknownType { + public func helper() {} + } + """ + + let annotatedSource = + """ + @JavaExport + public struct Exported { + public var name: String + @JavaExport(false) public var hidden: String + } + public struct Partial { + @JavaExport public func picked() {} + public func notPicked() {} + } + public struct Unmarked { + public func pairs() -> [(Int64, String)] { [] } + } + @JavaExport public func topLevel() {} + public func notExported() {} + extension UnknownType { + public func helper() {} + } + """ + + private func config(_ exportMode: JExtractExportMode) -> Configuration { + var config = Configuration() + config.swiftModule = "SwiftModule" + config.exportMode = exportMode + return config + } + + /// Generates Java and Swift, returning the Java and every declaration that was skipped along the way. + /// `dependencies` maps a dependency module's name to its source and export mode. + private func generate( + _ source: String, + _ exportMode: JExtractExportMode, + dependencies: [String: (source: String, exportMode: JExtractExportMode)] = [:] + ) throws -> (java: String, skipped: [String]) { + let config = config(exportMode) + let sink = SkippedDeclarationCollector() + let dependencyConfigs = dependencies.map { name, dependency in + var dependencyConfig = Configuration() + dependencyConfig.exportMode = dependency.exportMode + return DependencyConfig(swiftModuleName: name, configuration: dependencyConfig) + } + let translator = makeSwiftJavaAnalyzer(config: config, dependencyConfigs: dependencyConfigs, diagnosticsSink: sink) + for (name, dependency) in dependencies { + let input = SwiftInputFile(syntax: Parser.parse(source: dependency.source), path: "/fake/\(name).swift") + translator.sourceDependencies.swiftModuleInputs[name] = [input] + } + try translator.analyze(path: "/fake/Fake.swiftinterface", text: source) + let generator = JNISwift2JavaGenerator( + config: config, + translator: translator, + javaPackage: "com.example.swift", + swiftOutputDirectory: "/fake", + javaOutputDirectory: "/fake", + javaClassLookupTable: [:], + moduleJavaPackages: Dictionary(uniqueKeysWithValues: dependencies.keys.map { ($0, "com.example.\($0.lowercased())") }) + ) + var javaPrinter = JavaPrinter(mode: .accumulateAll) + try generator.writeExportedJavaSources(&javaPrinter) + var swiftPrinter = SwiftPrinter(mode: .accumulateAll) + try generator.writeSwiftThunkSources(&swiftPrinter) + return (javaPrinter.finalize(), sink.skipped + generator.skippedDeclarations) + } + + @Test + func optedOutDeclarationsAreNotExported() throws { + try assertOutput( + input: optOutSource, + config: config(.all), + .jni, + .java, + expectedChunks: [ + "public java.lang.String getToken()", + "public long getSeconds()", + ], + notExpectedChunks: [ + "getSecret", + "pairs", + "Hidden", + "fromExtension", + ] + ) + } + + @Test + func optedOutAndOperatorDeclarationsAreNotSkipped() throws { + let skipped = try generate(optOutSource, .all).skipped + #expect(skipped.isEmpty, "\(skipped)") + } + + @Test + func annotatedModeExportsOnlyMarkedDeclarations() throws { + try assertOutput( + input: annotatedSource, + config: config(.annotated), + .jni, + .java, + expectedChunks: [ + "public java.lang.String getName()", + "public void picked()", + "public static void topLevel()", + ], + notExpectedChunks: [ + "getHidden", + "notPicked", + "Unmarked", + "notExported", + ] + ) + } + + @Test + func annotatedModeDoesNotSkipUnmarkedDeclarations() throws { + let skipped = try generate(annotatedSource, .annotated).skipped + #expect(skipped.isEmpty, "\(skipped)") + } + + @Test + func annotatedModeKeepsFoundationTypes() throws { + let (java, skipped) = try generate( + """ + import Foundation + @JavaExport + public struct Event { + public var date: Date + public var link: URL + } + """, + .annotated + ) + #expect(skipped.isEmpty, "\(skipped)") + #expect(java.contains("public Date getDate(SwiftArena swiftArena)"), "\(java)") + #expect(java.contains("public final class URL"), "\(java)") + } + + @Test + func declarationsUsingUnexportedTypesAreSkipped() throws { + let (java, skipped) = try generate( + """ + public struct Internal {} + @JavaExport + public struct Uses { + public var name: String + public var internal: Internal + public func make() -> [Internal] { [] } + } + """, + .annotated + ) + #expect(java.contains("public java.lang.String getName()"), "\(java)") + #expect(!java.contains("getInternal"), "\(java)") + #expect(!java.contains("make("), "\(java)") + #expect(skipped.contains { $0.hasPrefix("Uses.internal") && $0.contains("'Internal', which is not exported") }, "\(skipped)") + } + + @Test + func declarationsUsingUnexportedDependencyTypesAreSkipped() throws { + let models = """ + public struct Cache {} + @JavaExport + public struct Item {} + """ + let source = """ + import Models + @JavaExport + public struct Screen { + public var item: Item + public var cache: Cache + } + """ + let annotated = try generate(source, .annotated, dependencies: ["Models": (models, .annotated)]) + #expect(annotated.java.contains("getItem"), "\(annotated.java)") + #expect(!annotated.java.contains("getCache"), "\(annotated.java)") + #expect(annotated.skipped.contains { $0.contains("'Cache', which is not exported") }, "\(annotated.skipped)") + + // A dependency that exports everything exports `Cache` too. + let all = try generate(source, .annotated, dependencies: ["Models": (models, .all)]) + #expect(all.java.contains("getCache"), "\(all.java)") + } +}