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 @@ -66,6 +66,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: "JavaUtil",
targets: ["JavaUtil"]
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
103 changes: 87 additions & 16 deletions Sources/JExtractSwiftLib/JavaExtractDecider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
)
Expand All @@ -33,17 +43,36 @@ 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
/// - 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 @@ -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
}
}
2 changes: 1 addition & 1 deletion Sources/JExtractSwiftLib/Swift2Java.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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
1 change: 1 addition & 0 deletions Sources/SwiftJava/CSwiftJavaJNI+Reexport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@
//
//===----------------------------------------------------------------------===//

@_exported import SwiftJavaAnnotations
@_exported import SwiftJavaJNICore
12 changes: 0 additions & 12 deletions Sources/SwiftJava/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fish>
/// ```
/// This tells jextract to generate a concrete `FishTank` Java class for `Tank<Fish>`,
/// even if `Tank` was included in the `filterExclude` configuration.
@attached(peer)
public macro JavaExport() =
#externalMacro(module: "SwiftJavaMacros", type: "JavaExportMacro")
41 changes: 41 additions & 0 deletions Sources/SwiftJavaAnnotations/JavaExport.swift
Original file line number Diff line number Diff line change
@@ -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<Fish>
/// ```
/// This tells jextract to generate a concrete `FishTank` Java class for `Tank<Fish>`,
/// 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")
8 changes: 8 additions & 0 deletions Sources/SwiftJavaConfigurationShared/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?`
Expand Down
Loading