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 } 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) + } + } +}