Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
98 changes: 83 additions & 15 deletions Sources/JExtractSwiftLib/JavaExtractDecider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,17 +45,36 @@ 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
/// - Skips Swift operators (`+`, `-`, prefix/postfix forms) — Java has no
/// 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)
}

Expand All @@ -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
}
}
6 changes: 5 additions & 1 deletion Sources/JExtractSwiftLib/Swift2Java.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 41 additions & 3 deletions Sources/JExtractSwiftLib/SwiftSyntax+Java.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down
18 changes: 18 additions & 0 deletions Sources/SwiftExtract/ExtractDecider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading