From c472256424a0658886ff555770a2e3ffafa6e876 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 13:19:19 +0100 Subject: [PATCH 1/2] JExtract: report every skipped declaration and add failOnSkippedDeclarations Declarations that analysis accepted but JNI translation could not handle were dropped with a debug-level log line only, so missing bindings went unnoticed (for example `[T?]` and `[[K: V]]` return types). Even `"logLevel": "debug"` in swift-java.config didn't show them, because the CLI's default `--log-level` always overwrote the config's value. - Log JNI translation failures as warnings, in the same form as analysis skips. - Cache failed translations: assigning nil to the cache dictionary removed the key, so every failure was re-translated (and re-reported) on each pass. - Add `failOnSkippedDeclarations`: after generating, jextract fails and lists every declaration skipped during analysis or JNI translation. The list is part of the error, since the tool exits through fatalError and log lines on a piped stdout aren't flushed. - Only override the config's `logLevel` when `--log-level` is passed. Co-Authored-By: Claude Opus 5.5 --- Snippets/SwiftJavaCLIHelp.txt | 12 ++- ...ISwift2JavaGenerator+JavaTranslation.swift | 17 +++- .../JNI/JNISwift2JavaGenerator.swift | 7 +- .../JExtractSwiftLib/JavaExtractDecider.swift | 6 +- Sources/JExtractSwiftLib/Swift2Java.swift | 39 +++++++- .../Configuration.swift | 7 ++ .../Documentation.docc/SwiftJavaConfigFile.md | 10 ++ Sources/SwiftJavaTool/CommonOptions.swift | 7 +- .../SwiftJavaBaseAsyncParsableCommand.swift | 8 +- .../JNI/JNISkippedDeclarationsTests.swift | 91 +++++++++++++++++++ 10 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift diff --git a/Snippets/SwiftJavaCLIHelp.txt b/Snippets/SwiftJavaCLIHelp.txt index 97494f340..161a547cc 100644 --- a/Snippets/SwiftJavaCLIHelp.txt +++ b/Snippets/SwiftJavaCLIHelp.txt @@ -57,8 +57,9 @@ OPTIONS: and --output-swift. -l, --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 A path to a custom swift-java.config to use --cp, --classpath Class search path of directories and zip/jar files from which Java classes can be loaded. @@ -115,8 +116,9 @@ OPTIONS: and --output-swift. -l, --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 A path to a custom swift-java.config to use --cp, --classpath Class search path of directories and zip/jar files from which Java classes can be loaded. @@ -145,8 +147,9 @@ OPTIONS: and --output-swift. -l, --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 A path to a custom swift-java.config to use --cp, --classpath Class search path of directories and zip/jar files from which Java classes can be loaded. @@ -207,8 +210,9 @@ OPTIONS: and --output-swift. -l, --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 A path to a custom swift-java.config to use --mode The mode of generation to use for the output files. Used with jextract mode. diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift index 9dc93d507..713e809ae 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+JavaTranslation.swift @@ -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 = [ "getMessage", "getLocalizedMessage", "getCause", "fillInStackTrace", "printStackTrace", "getStackTrace", @@ -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 } diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift index 6a1a23a41..0e116d934 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator.swift @@ -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 = [] @@ -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 = Set( diff --git a/Sources/JExtractSwiftLib/JavaExtractDecider.swift b/Sources/JExtractSwiftLib/JavaExtractDecider.swift index a64ef4466..682002e52 100644 --- a/Sources/JExtractSwiftLib/JavaExtractDecider.swift +++ b/Sources/JExtractSwiftLib/JavaExtractDecider.swift @@ -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 ) } diff --git a/Sources/JExtractSwiftLib/Swift2Java.swift b/Sources/JExtractSwiftLib/Swift2Java.swift index e07338101..1da75afe7 100644 --- a/Sources/JExtractSwiftLib/Swift2Java.swift +++ b/Sources/JExtractSwiftLib/Swift2Java.swift @@ -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 { @@ -128,6 +129,7 @@ public struct SwiftToJava { ) try generator.generate() + try failIfSkipped(skippedDuringAnalysis.skipped, module: swiftModule, log: log) case .jni: let generator = JNISwift2JavaGenerator( @@ -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 { @@ -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")) + """ + } +} diff --git a/Sources/SwiftJavaConfigurationShared/Configuration.swift b/Sources/SwiftJavaConfigurationShared/Configuration.swift index c45b00b2e..105c19b04 100644 --- a/Sources/SwiftJavaConfigurationShared/Configuration.swift +++ b/Sources/SwiftJavaConfigurationShared/Configuration.swift @@ -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? diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md index bacce7ccf..d88c717ad 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/SwiftJavaConfigFile.md @@ -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?` diff --git a/Sources/SwiftJavaTool/CommonOptions.swift b/Sources/SwiftJavaTool/CommonOptions.swift index 0be3c210c..555f0bc33 100644 --- a/Sources/SwiftJavaTool/CommonOptions.swift +++ b/Sources/SwiftJavaTool/CommonOptions.swift @@ -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 diff --git a/Sources/SwiftJavaTool/SwiftJavaBaseAsyncParsableCommand.swift b/Sources/SwiftJavaTool/SwiftJavaBaseAsyncParsableCommand.swift index b5550cd9d..274913b00 100644 --- a/Sources/SwiftJavaTool/SwiftJavaBaseAsyncParsableCommand.swift +++ b/Sources/SwiftJavaTool/SwiftJavaBaseAsyncParsableCommand.swift @@ -106,7 +106,7 @@ extension SwiftJavaBaseAsyncParsableCommand { var logLevel: LogLevel { get { - self.commonOptions.logLevel + self.commonOptions.logLevel ?? .info } set { self.commonOptions.logLevel = newValue @@ -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 } } diff --git a/Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift b/Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift new file mode 100644 index 000000000..ee6c94dcb --- /dev/null +++ b/Tests/JExtractSwiftTests/JNI/JNISkippedDeclarationsTests.swift @@ -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) + } + } +} From 62fe3de6656f9063c6d261bf3d4249a2342ce21f Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Tue, 3 Nov 2026 15:35:58 +0100 Subject: [PATCH 2/2] Samples: fail the Kotlin sample's build on skipped declarations With failOnSkippedDeclarations, the sample's bindings must cover every public declaration: jextract fails instead of silently dropping one. Co-Authored-By: Claude Opus 5.5 --- .../Sources/KotlinSampleLibrary/swift-java.config | 3 ++- .../Sources/KotlinSampleModels/swift-java.config | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config index 12c9d9e63..62bcfc169 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config @@ -1,5 +1,6 @@ { "javaPackage": "com.example.kotlin", "mode": "jni", - "memoryManagementMode": "allowGlobalAutomatic" + "memoryManagementMode": "allowGlobalAutomatic", + "failOnSkippedDeclarations": true } diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/swift-java.config index 53b179ce0..09fa44001 100644 --- a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/swift-java.config +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/swift-java.config @@ -1,5 +1,6 @@ { "javaPackage": "com.example.kotlin.models", "mode": "jni", - "memoryManagementMode": "allowGlobalAutomatic" + "memoryManagementMode": "allowGlobalAutomatic", + "failOnSkippedDeclarations": true }