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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"javaPackage": "com.example.kotlin",
"mode": "jni",
"memoryManagementMode": "allowGlobalAutomatic"
"memoryManagementMode": "allowGlobalAutomatic",
"failOnSkippedDeclarations": true
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"javaPackage": "com.example.kotlin.models",
"mode": "jni",
"memoryManagementMode": "allowGlobalAutomatic"
"memoryManagementMode": "allowGlobalAutomatic",
"failOnSkippedDeclarations": true
}
12 changes: 8 additions & 4 deletions Snippets/SwiftJavaCLIHelp.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ OPTIONS:
and --output-swift.
-l, --log-level <log-level>
Configure the level of logs that should be printed
(default: the config's 'logLevel', or 'info')
(values: trace, debug, info, notice, warning, error,
critical; default: log level)
critical)
--config <config> A path to a custom swift-java.config to use
--cp, --classpath <cp> Class search path of directories and zip/jar files
from which Java classes can be loaded.
Expand Down Expand Up @@ -115,8 +116,9 @@ OPTIONS:
and --output-swift.
-l, --log-level <log-level>
Configure the level of logs that should be printed
(default: the config's 'logLevel', or 'info')
(values: trace, debug, info, notice, warning, error,
critical; default: log level)
critical)
--config <config> A path to a custom swift-java.config to use
--cp, --classpath <cp> Class search path of directories and zip/jar files
from which Java classes can be loaded.
Expand Down Expand Up @@ -145,8 +147,9 @@ OPTIONS:
and --output-swift.
-l, --log-level <log-level>
Configure the level of logs that should be printed
(default: the config's 'logLevel', or 'info')
(values: trace, debug, info, notice, warning, error,
critical; default: log level)
critical)
--config <config> A path to a custom swift-java.config to use
--cp, --classpath <cp> Class search path of directories and zip/jar files
from which Java classes can be loaded.
Expand Down Expand Up @@ -207,8 +210,9 @@ OPTIONS:
and --output-swift.
-l, --log-level <log-level>
Configure the level of logs that should be printed
(default: the config's 'logLevel', or 'info')
(values: trace, debug, info, notice, warning, error,
critical; default: log level)
critical)
--config <config> A path to a custom swift-java.config to use
--mode <mode> The mode of generation to use for the output files.
Used with jextract mode.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,27 @@ extension JNISwift2JavaGenerator {
do {
translated = try self.javaTranslator.translate(decl)
} catch {
self.logger.debug("Failed to translate: '\(decl.swiftDecl.qualifiedNameForDebug)'; \(error)")
reportSkipped(decl.swiftDecl.qualifiedNameForDebug, reason: "\(error)")
translated = nil
}

if let candidate = translated, collidesWithThrowableMember(candidate, of: decl) {
skippedDeclarations.append("\(decl.swiftDecl.qualifiedNameForDebug): collides with a java.lang.Throwable member")
translated = nil
}

translatedDecls[decl] = translated
// `updateValue` so a failure (nil) is cached too; subscript-assigning nil would remove the key.
translatedDecls.updateValue(translated, forKey: decl)
return translated
}

/// Logs a declaration that analysis accepted but JNI translation could not handle, and records it
/// for `failOnSkippedDeclarations`. These used to be logged at debug level only.
private func reportSkipped(_ name: String, reason: String) {
self.logger.warning("Failed to translate: '\(name)' in module '\(swiftModuleName)'; \(reason)")
skippedDeclarations.append("\(name): \(reason)")
}

/// Zero-argument `Throwable` members that a generated `SwiftError` subclass cannot redeclare with another meaning.
static let throwableMemberNames: Set<String> = [
"getMessage", "getLocalizedMessage", "getCause", "fillInStackTrace", "printStackTrace", "getStackTrace",
Expand Down Expand Up @@ -111,11 +120,11 @@ extension JNISwift2JavaGenerator {
)
translated = try translation.translate(enumCase: decl)
} catch {
self.logger.debug("Failed to translate: '\(decl.swiftDecl.qualifiedNameForDebug)'; \(error)")
reportSkipped(decl.swiftDecl.qualifiedNameForDebug, reason: "\(error)")
translated = nil
}

translatedEnumCases[decl] = translated
translatedEnumCases.updateValue(translated, forKey: decl)
return translated
}

Expand Down
7 changes: 5 additions & 2 deletions Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator {

var thunkNameRegistry = ThunkNameRegistry()

/// Declarations JNI translation skipped, as "name: reason", for `failOnSkippedDeclarations`.
package var skippedDeclarations: [String] = []

/// Jextracted types that already have a minimal JavaKit class wrapper printed for closures.
var printedMinimalJavaClassWrappers: Set<String> = []

Expand All @@ -53,8 +56,8 @@ package class JNISwift2JavaGenerator: Swift2JavaGenerator {
var generatedCDeclSymbolNames: [String] = []

/// Cached Java translation result. 'nil' indicates failed translation.
var translatedDecls: [ExtractedFunc: TranslatedFunctionDecl] = [:]
var translatedEnumCases: [ExtractedEnumCase: TranslatedEnumCase] = [:]
var translatedDecls: [ExtractedFunc: TranslatedFunctionDecl?] = [:]
var translatedEnumCases: [ExtractedEnumCase: TranslatedEnumCase?] = [:]

/// Module-qualified identities of extracted types that are surfaced to Java as `SwiftError` exceptions.
lazy var swiftErrorTypes: Set<SwiftNominalIdentity> = Set(
Expand Down
6 changes: 5 additions & 1 deletion Sources/JExtractSwiftLib/JavaExtractDecider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ import SwiftExtract
import SwiftJavaConfigurationShared
import SwiftSyntax

public func makeSwiftJavaAnalyzer(config: Configuration) -> SwiftAnalyzer {
public func makeSwiftJavaAnalyzer(
config: Configuration,
diagnosticsSink: (any SwiftExtractDiagnosticsSink)? = nil
) -> SwiftAnalyzer {
SwiftAnalyzer(
config: config,
extractDecider: JavaExtractDecider(
accessLevel: config.effectiveMinimumInputAccessLevelMode,
logLevel: config.logLevel ?? .info
),
diagnosticsSink: diagnosticsSink
)
}

Expand Down
39 changes: 38 additions & 1 deletion Sources/JExtractSwiftLib/Swift2Java.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ public struct SwiftToJava {

let javaPackage = try validateJavaPackage(config.javaPackage)

let translator = makeSwiftJavaAnalyzer(config: config)
let skippedDuringAnalysis = SkippedDeclarationCollector()
let translator = makeSwiftJavaAnalyzer(config: config, diagnosticsSink: skippedDuringAnalysis)
let log = translator.log

guard let inputSwift = config.inputSwiftDirectory else {
Expand Down Expand Up @@ -128,6 +129,7 @@ public struct SwiftToJava {
)

try generator.generate()
try failIfSkipped(skippedDuringAnalysis.skipped, module: swiftModule, log: log)

case .jni:
let generator = JNISwift2JavaGenerator(
Expand All @@ -141,11 +143,20 @@ public struct SwiftToJava {
)

try generator.generate()
try failIfSkipped(skippedDuringAnalysis.skipped + generator.skippedDeclarations, module: swiftModule, log: log)
}

print("[swift-java] Imported Swift module '\(swiftModule)': " + "done.".green)
}

/// With `failOnSkippedDeclarations`, turns skipped declarations into a failure that lists them all.
package func failIfSkipped(_ skipped: [String], module: String, log: Logger) throws {
guard config.effectiveFailOnSkippedDeclarations, !skipped.isEmpty else {
return
}
throw SkippedDeclarationsError(module: module, skipped: skipped)
}

/// Compute a relative path (sans `.swift` extension) for a file against the
/// input paths, suitable for jextract filter matching
func computeRelativePath(file: URL, inputPaths: [URL]) -> String {
Expand Down Expand Up @@ -267,3 +278,29 @@ private func collectFilesFromPath(
}
allFiles.append(path)
}

/// Collects declarations skipped during analysis.
package final class SkippedDeclarationCollector: SwiftExtractDiagnosticsSink {
package private(set) var skipped: [String] = []

package init() {}

package func emit(_ diagnostic: SwiftExtractDiagnostic) {
guard case .skippedDeclaration = diagnostic.kind else { return }
skipped.append("\(diagnostic.declarationName): \(diagnostic.message)")
}
}

package struct SkippedDeclarationsError: Error, CustomStringConvertible {
let module: String
let skipped: [String]

// The list goes in the error itself: the tool fails through `fatalError`, which doesn't flush
// log lines written to a piped stdout (as when run by the SwiftPM plugin).
package var description: String {
"""
jextract skipped \(skipped.count) declaration(s) in module '\(module)' and 'failOnSkippedDeclarations' is set:
\(skipped.map { " - \($0)" }.joined(separator: "\n"))
"""
}
}
7 changes: 7 additions & 0 deletions Sources/SwiftJavaConfigurationShared/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ public struct Configuration: Codable {
enableJavaCallbacks ?? false
}

/// 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?
public var effectiveFailOnSkippedDeclarations: Bool {
failOnSkippedDeclarations ?? false
}

/// 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
Expand Up @@ -246,6 +246,16 @@ only supported in `jni` mode.

---

#### failOnSkippedDeclarations

- **Type:** `Bool?`
- **Default:** `false`

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.

---

#### generatedJavaSourcesListFileOutput

- **Type:** `String?`
Expand Down
7 changes: 5 additions & 2 deletions Sources/SwiftJavaTool/CommonOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ extension SwiftJava {
)
var inputSwift: String? = nil

@Option(name: .shortAndLong, help: "Configure the level of logs that should be printed")
var logLevel: LogLevel = .info
@Option(
name: .shortAndLong,
help: "Configure the level of logs that should be printed (default: the config's 'logLevel', or 'info')"
)
var logLevel: LogLevel? = nil

@Option(help: "A path to a custom swift-java.config to use")
var config: String? = nil
Expand Down
8 changes: 6 additions & 2 deletions Sources/SwiftJavaTool/SwiftJavaBaseAsyncParsableCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ extension SwiftJavaBaseAsyncParsableCommand {

var logLevel: LogLevel {
get {
self.commonOptions.logLevel
self.commonOptions.logLevel ?? .info
}
set {
self.commonOptions.logLevel = newValue
Expand Down Expand Up @@ -183,7 +183,11 @@ extension SwiftJavaBaseAsyncParsableCommand {
config = Configuration()
}
// override configuration with options from command line
config.logLevel = command.logLevel
if let logLevel = command.commonOptions.logLevel {
config.logLevel = logLevel
} else if config.logLevel == nil {
config.logLevel = .info
}
return config
}
}
91 changes: 91 additions & 0 deletions Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//===----------------------------------------------------------------------===//
//
// 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

/// Declarations jextract skips are reported, and `failOnSkippedDeclarations` turns them into a failure.
@Suite
struct JNISkippedDeclarationsTests {
let source =
"""
public struct Tag {
public var name: String
}
public func supported() -> Tag { fatalError() }
public func sparse() -> [Tag?] { [] }
public func nested() -> [[String: Int64]] { [] }
"""

private func generate(_ source: String, sink: SkippedDeclarationCollector? = nil) throws -> JNISwift2JavaGenerator {
var config = Configuration()
config.swiftModule = "SwiftModule"
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 generator
}

@Test
func translationFailuresAreRecordedOnce() throws {
let generator = try generate(source)
let skipped = generator.skippedDeclarations.sorted()
#expect(skipped.count == 2, "\(skipped)")
#expect(skipped[0].hasPrefix("nested()"))
#expect(skipped[1].hasPrefix("sparse()"))
}

@Test
func analysisSkipsAreCollected() throws {
let sink = SkippedDeclarationCollector()
_ = try generate("public struct S { public static let untyped = 42 }", sink: sink)
#expect(sink.skipped.count == 1, "\(sink.skipped)")
#expect(sink.skipped.first?.hasPrefix("S.untyped") == true)
}

@Test
func failsOnlyWhenConfigured() throws {
let skipped = ["sparse(): unsupported"]
var config = Configuration()
let log = Logger(label: "test", logLevel: .critical)

#expect(throws: Never.self) {
try SwiftToJava(config: config, dependencyConfigs: []).failIfSkipped(skipped, module: "M", log: log)
}

config.failOnSkippedDeclarations = true
let error = #expect(throws: SkippedDeclarationsError.self) {
try SwiftToJava(config: config, dependencyConfigs: []).failIfSkipped(skipped, module: "M", log: log)
}
#expect(error?.description.contains(" - sparse(): unsupported") == true)
#expect(throws: Never.self) {
try SwiftToJava(config: config, dependencyConfigs: []).failIfSkipped([], module: "M", log: log)
}
}
}
Loading