From 89f9602c5da3c76c776c5aa19a179f80032de295 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 11:19:52 +0100 Subject: [PATCH 1/4] JNI: choose what jextract exports with @JavaExport(false) and exportMode `@JavaExport(false)` keeps a declaration, or every member of a type or extension, out of the Java bindings. `"exportMode": "annotated"` exports only declarations marked `@JavaExport` and the members of marked types and extensions. Both are decided in JavaExtractDecider, so declarations that are not exported are never translated and can't be skipped, miscompiled or crash jextract. An extension of an unresolved type whose members can't be exported is no longer reported as skipped. Prefix and postfix operators are now recognized by their modifier: the name-token check let `prefix static func -` through to the thunk printer, which crashed. The JavaExport macro moves to a new SwiftJavaAnnotations module without the JNI dependency; SwiftJava re-exports it. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 16 ++ .../JExtractSwiftLib/JavaExtractDecider.swift | 60 +++++-- .../JExtractSwiftLib/SwiftSyntax+Java.swift | 44 ++++- Sources/SwiftExtract/ExtractDecider.swift | 10 ++ .../SwiftExtract/SwiftAnalysisVisitor.swift | 4 + .../SwiftJava/CSwiftJavaJNI+Reexport.swift | 1 + Sources/SwiftJava/Macros.swift | 12 -- Sources/SwiftJavaAnnotations/JavaExport.swift | 41 +++++ .../Configuration.swift | 8 + .../JExtract/JExtractExportMode.swift | 28 ++++ .../Documentation.docc/SwiftJavaConfigFile.md | 16 ++ .../Documentation.docc/SwiftJavaJextract.md | 27 ++++ .../JNI/JNIExportControlTests.swift | 151 ++++++++++++++++++ 13 files changed, 388 insertions(+), 30 deletions(-) create mode 100644 Sources/SwiftJavaAnnotations/JavaExport.swift create mode 100644 Sources/SwiftJavaConfigurationShared/JExtract/JExtractExportMode.swift create mode 100644 Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift diff --git a/Package.swift b/Package.swift index 14bd36aea..e97e69c72 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,20 @@ let package = Package( .swiftLanguageMode(.v5) ] ), + .target( + name: "SwiftJavaAnnotations", + dependencies: [ + "SwiftJavaMacros" + ], + swiftSettings: [ + .swiftLanguageMode(.v5) + ] + ), .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 682002e52..52906f8a8 100644 --- a/Sources/JExtractSwiftLib/JavaExtractDecider.swift +++ b/Sources/JExtractSwiftLib/JavaExtractDecider.swift @@ -24,6 +24,7 @@ public func makeSwiftJavaAnalyzer( config: config, extractDecider: JavaExtractDecider( accessLevel: config.effectiveMinimumInputAccessLevelMode, + exportMode: config.effectiveExportMode, logLevel: config.logLevel ?? .info ), diagnosticsSink: diagnosticsSink @@ -37,6 +38,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 +50,12 @@ 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 let log: Logger - public init(accessLevel: AccessLevelMode = .default, logLevel: LogLevel = .info) { + public init(accessLevel: AccessLevelMode = .default, exportMode: JExtractExportMode = .default, logLevel: LogLevel = .info) { self.accessLevel = accessLevel + self.exportMode = exportMode self.log = Logger(label: "JavaExtractDecider", logLevel: logLevel) } @@ -66,33 +74,55 @@ 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 { + log.trace("Skip '\(decl.qualifiedNameForDebug)': not annotated @JavaExport (exportMode: annotated)") + return false + } + return true + } + + 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/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..1875e0300 100644 --- a/Sources/SwiftExtract/ExtractDecider.swift +++ b/Sources/SwiftExtract/ExtractDecider.swift @@ -38,6 +38,16 @@ 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 +} + +extension ExtractDecider { + public func mayExtractMembers(of extensionDecl: ExtensionDeclSyntax) -> 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..4b82df604 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)", 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..a18cbcaac --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift @@ -0,0 +1,151 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 every declaration that was skipped along the way. + private func skipped(_ source: String, _ exportMode: JExtractExportMode) throws -> [String] { + let config = config(exportMode) + let sink = SkippedDeclarationCollector() + let translator = makeSwiftJavaAnalyzer(config: config, diagnosticsSink: sink) + 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: [:] + ) + var javaPrinter = JavaPrinter(mode: .accumulateAll) + try generator.writeExportedJavaSources(&javaPrinter) + var swiftPrinter = SwiftPrinter(mode: .accumulateAll) + try generator.writeSwiftThunkSources(&swiftPrinter) + return 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 skipped(optOutSource, .all) + #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 skipped(annotatedSource, .annotated) + #expect(skipped.isEmpty, "\(skipped)") + } +} From c8fe19524cf1c871357c8f2f0dbf827612b27db0 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 11:19:52 +0100 Subject: [PATCH 2/4] Samples: opt Swift-only members out of the Kotlin API Co-Authored-By: Claude Opus 5.5 --- .../KotlinSampleLibrary/ExportControl.swift | 42 +++++++++++++++++++ .../com/example/kotlin/ExportControlTest.kt | 34 +++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ExportControl.swift create mode 100644 Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ExportControlTest.kt 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") + } +} From 4e83e3a9a7990a5eacb47d702c42f83f8c89e6b3 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 12:13:14 +0100 Subject: [PATCH 3/4] SwiftJavaAnnotations builds in Swift 6 language mode Co-Authored-By: Claude Opus 5.5 --- Package.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Package.swift b/Package.swift index e97e69c72..6fcb0bcb8 100644 --- a/Package.swift +++ b/Package.swift @@ -206,9 +206,6 @@ let package = Package( name: "SwiftJavaAnnotations", dependencies: [ "SwiftJavaMacros" - ], - swiftSettings: [ - .swiftLanguageMode(.v5) ] ), .target( From ce6b6be233e69024c905318b552ecfdd0cbc504f Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Wed, 4 Nov 2026 14:23:31 +0100 Subject: [PATCH 4/4] JNI: in annotated mode, keep Foundation types and skip uses of unexported types Two gaps in `exportMode: annotated`: - The `Data`, `Date` and `URL` wrapper types jextract extracts when a module uses them come from SwiftExtract's own description of Foundation, which has no `@JavaExport` markers, so annotated mode dropped them while exported declarations still referred to them. Declarations from SwiftExtract's known module sources are no longer filtered by export mode. - A declaration whose signature used a type that isn't exported, from this module or a dependency, produced Java referring to a class that doesn't exist. ExtractDecider.isExported(_:) now answers that, using each dependency's `exportMode` from its swift-java.config, and such declarations are skipped with "uses 'T', which is not exported". Co-Authored-By: Claude Opus 5.5 --- .../JExtractSwiftLib/JavaExtractDecider.swift | 42 ++++++++- Sources/JExtractSwiftLib/Swift2Java.swift | 6 +- Sources/SwiftExtract/ExtractDecider.swift | 8 ++ .../SwiftExtract/SwiftAnalysisVisitor.swift | 49 ++++++++++ .../SwiftTypes/SwiftKnownModules.swift | 9 ++ .../JNI/JNIExportControlTests.swift | 92 +++++++++++++++++-- 6 files changed, 196 insertions(+), 10 deletions(-) diff --git a/Sources/JExtractSwiftLib/JavaExtractDecider.swift b/Sources/JExtractSwiftLib/JavaExtractDecider.swift index 52906f8a8..4713bfdd1 100644 --- a/Sources/JExtractSwiftLib/JavaExtractDecider.swift +++ b/Sources/JExtractSwiftLib/JavaExtractDecider.swift @@ -18,6 +18,7 @@ import SwiftSyntax public func makeSwiftJavaAnalyzer( config: Configuration, + dependencyConfigs: [DependencyConfig] = [], diagnosticsSink: (any SwiftExtractDiagnosticsSink)? = nil ) -> SwiftAnalyzer { SwiftAnalyzer( @@ -25,6 +26,12 @@ public func makeSwiftJavaAnalyzer( 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 @@ -51,11 +58,23 @@ public func makeSwiftJavaAnalyzer( 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, exportMode: JExtractExportMode = .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) } @@ -111,13 +130,32 @@ public struct JavaExtractDecider: ExtractDecider { return false } - if exportMode == .annotated, inheritedMarker != true, !decl.containsJavaExportedMember { + 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 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/SwiftExtract/ExtractDecider.swift b/Sources/SwiftExtract/ExtractDecider.swift index 1875e0300..3a543673e 100644 --- a/Sources/SwiftExtract/ExtractDecider.swift +++ b/Sources/SwiftExtract/ExtractDecider.swift @@ -42,12 +42,20 @@ public protocol ExtractDecider { /// 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 4b82df604..a7e898a2d 100644 --- a/Sources/SwiftExtract/SwiftAnalysisVisitor.swift +++ b/Sources/SwiftExtract/SwiftAnalysisVisitor.swift @@ -358,6 +358,7 @@ final class SwiftAnalysisVisitor { enclosingType: typeContext?.swiftType, lookupContext: analyzer.lookupContext, ) + try checkTypesAreExported(signature) } catch { self.reportSkipped( node, @@ -415,6 +416,7 @@ final class SwiftAnalysisVisitor { enclosingType: typeContext.swiftType, lookupContext: analyzer.lookupContext, ) + try checkTypesAreExported(signature) let caseName = caseElement.name.text.unescapedSwiftName let caseFunction = ExtractedFunc( @@ -518,6 +520,7 @@ final class SwiftAnalysisVisitor { enclosingType: typeContext.swiftType, lookupContext: analyzer.lookupContext, ) + try checkTypesAreExported(signature) } catch { self.reportSkipped( node, @@ -636,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, @@ -897,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/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift b/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift index a18cbcaac..67717bcc8 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIExportControlTests.swift @@ -16,6 +16,7 @@ import CodePrinting import JExtractSwiftLib import SwiftExtract import SwiftJavaConfigurationShared +import SwiftParser import Testing /// `@JavaExport(false)` opts declarations out, and `exportMode: annotated` exports only marked ones. @@ -74,11 +75,25 @@ struct JNIExportControlTests { return config } - /// Generates Java and Swift, returning every declaration that was skipped along the way. - private func skipped(_ source: String, _ exportMode: JExtractExportMode) throws -> [String] { + /// 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 translator = makeSwiftJavaAnalyzer(config: config, diagnosticsSink: sink) + 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, @@ -87,13 +102,13 @@ struct JNIExportControlTests { swiftOutputDirectory: "/fake", javaOutputDirectory: "/fake", javaClassLookupTable: [:], - moduleJavaPackages: [:] + 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 sink.skipped + generator.skippedDeclarations + return (javaPrinter.finalize(), sink.skipped + generator.skippedDeclarations) } @Test @@ -118,7 +133,7 @@ struct JNIExportControlTests { @Test func optedOutAndOperatorDeclarationsAreNotSkipped() throws { - let skipped = try skipped(optOutSource, .all) + let skipped = try generate(optOutSource, .all).skipped #expect(skipped.isEmpty, "\(skipped)") } @@ -145,7 +160,70 @@ struct JNIExportControlTests { @Test func annotatedModeDoesNotSkipUnmarkedDeclarations() throws { - let skipped = try skipped(annotatedSource, .annotated) + 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)") } }