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/Sources/JExtractSwiftLib/JavaExtractDecider.swift b/Sources/JExtractSwiftLib/JavaExtractDecider.swift index a64ef4466..6f239751b 100644 --- a/Sources/JExtractSwiftLib/JavaExtractDecider.swift +++ b/Sources/JExtractSwiftLib/JavaExtractDecider.swift @@ -16,11 +16,21 @@ import SwiftExtract import SwiftJavaConfigurationShared import SwiftSyntax -public func makeSwiftJavaAnalyzer(config: Configuration) -> SwiftAnalyzer { +public func makeSwiftJavaAnalyzer( + config: Configuration, + dependencyConfigs: [DependencyConfig] = [] +) -> 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 ), ) @@ -33,6 +43,11 @@ public func makeSwiftJavaAnalyzer(config: Configuration) -> SwiftAnalyzer { /// - 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 @@ -40,10 +55,24 @@ public func makeSwiftJavaAnalyzer(config: Configuration) -> SwiftAnalyzer { /// 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) } @@ -62,33 +91,75 @@ 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. A declaration's operator name is + // lexed as a binary operator, so only the `prefix` or `postfix` modifier tells them apart. + 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 e07338101..47b168d81 100644 --- a/Sources/JExtractSwiftLib/Swift2Java.swift +++ b/Sources/JExtractSwiftLib/Swift2Java.swift @@ -37,7 +37,7 @@ public struct SwiftToJava { let javaPackage = try validateJavaPackage(config.javaPackage) - let translator = makeSwiftJavaAnalyzer(config: config) + let translator = makeSwiftJavaAnalyzer(config: config, dependencyConfigs: dependencyConfigs) 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 d3cf9ffa8..0eb82f662 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 c45b00b2e..982c788d8 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 specified, JExtract will output to this file a list of paths to all generated Java source files. public var generatedJavaSourcesListFileOutput: String? 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 bacce7ccf..b7e2eb670 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. + +--- + #### generatedJavaSourcesListFileOutput - **Type:** `String?` diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaJextract.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaJextract.md index 256028084..0e6f44526 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 Swift-only API that has no Java +mapping doesn't produce warnings or generated code. + ## See Also - diff --git a/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift b/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift new file mode 100644 index 000000000..57c18ad29 --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift @@ -0,0 +1,242 @@ +//===----------------------------------------------------------------------===// +// +// 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 load(_ request: URLRequest) {} + @JavaExport(false) public func pairs() -> [(Int64, String)] { [] } + } + @JavaExport(false) + public struct Hidden { + public func load(_ request: URLRequest) {} + } + @JavaExport(false) + extension Session { + public func fromExtension(_ request: URLRequest) {} + } + @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 load(_ request: URLRequest) {} + } + @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 + } + + /// Declarations the analyzer reports as skipped. + private func skipped(_ source: String, _ exportMode: JExtractExportMode) throws -> [String] { + try generate(source, exportMode).skipped + } + + /// Generates the Java bindings, returning them and each skipped declaration with its reason. + /// `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]) { + final class Sink: SwiftExtractDiagnosticsSink { + var skipped: [String] = [] + func emit(_ diagnostic: SwiftExtractDiagnostic) { + skipped.append("\(diagnostic.declarationName): \(diagnostic.message)") + } + } + let config = config(exportMode) + let sink = Sink() + let analyzer = SwiftAnalyzer( + config: config, + extractDecider: JavaExtractDecider( + exportMode: exportMode, + swiftModuleName: config.swiftModule, + dependencyExportModes: dependencies.mapValues(\.exportMode) + ), + diagnosticsSink: sink + ) + for (name, dependency) in dependencies { + let input = SwiftInputFile(syntax: Parser.parse(source: dependency.source), path: "/fake/\(name).swift") + analyzer.sourceDependencies.swiftModuleInputs[name] = [input] + } + try analyzer.analyze(path: "/fake/Fake.swiftinterface", text: source) + let generator = JNISwift2JavaGenerator( + config: config, + translator: analyzer, + javaPackage: "com.example.swift", + swiftOutputDirectory: "/fake", + javaOutputDirectory: "/fake", + javaClassLookupTable: [:], + moduleJavaPackages: Dictionary(uniqueKeysWithValues: dependencies.keys.map { ($0, "com.example.\($0.lowercased())") }) + ) + var printer = JavaPrinter(mode: .accumulateAll) + try generator.writeExportedJavaSources(&printer) + return (printer.finalize(), sink.skipped) + } + + @Test + func optedOutDeclarationsAreNotExported() throws { + try assertOutput( + input: optOutSource, + config: config(.all), + .jni, + .java, + expectedChunks: [ + "public java.lang.String getToken()" + ], + notExpectedChunks: [ + "getSecret", + "void load(", + "pairs", + "Hidden", + "fromExtension", + ] + ) + } + + @Test + func optedOutDeclarationsAreNotReportedAsSkipped() throws { + let skipped = try skipped(optOutSource, .all) + #expect(skipped.isEmpty, "\(skipped)") + // Without the markers, the same declarations are reported. + let unmarked = try self.skipped(optOutSource.replacingOccurrences(of: "@JavaExport(false)", with: ""), .all) + #expect(unmarked.count == 4, "\(unmarked)") + } + + @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", + "void load(", + ] + ) + } + + @Test + func unmarkedDeclarationsAreNotReportedAsSkipped() throws { + let skipped = try skipped(annotatedSource, .annotated) + #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)") + } +} diff --git a/Tests/JExtractSwiftTests/JNI/JNIOperatorsTests.swift b/Tests/JExtractSwiftTests/JNI/JNIOperatorsTests.swift index 9863d109d..1eb5d4240 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIOperatorsTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIOperatorsTests.swift @@ -149,4 +149,60 @@ struct JNIOperatorsTests { ] ) } + + let prefixAndPostfixSource = + """ + postfix operator ++ + + public struct Number { + public static prefix func - (value: Number) -> Number { + value + } + + public static postfix func ++ (value: Number) -> Number { + value + } + + public static func + (left: Number, right: Number) -> Number { + Number() + } + } + """ + + @Test + func prefixAndPostfixOperators_areSkipped_javaBindings() throws { + try assertOutput( + input: prefixAndPostfixSource, + .jni, + .java, + expectedChunks: [ + """ + private static native long $plus(long left, long right); + """ + ], + notExpectedChunks: [ + "prefix func", + "postfix func", + ] + ) + } + + @Test + func prefixAndPostfixOperators_areSkipped_swiftThunks() throws { + try assertOutput( + input: prefixAndPostfixSource, + .jni, + .swift, + detectChunkByInitialLines: 1, + expectedChunks: [ + """ + @_cdecl("Java_com_example_swift_Number__00024plus__JJ") + """ + ], + notExpectedChunks: [ + "minus", + "++", + ] + ) + } }