From 58740dc2bddd99034cbc52758e72739ce9c22734 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Fri, 21 Aug 2026 10:02:20 +0000 Subject: [PATCH] [SwiftExtract] Add a pluggable diagnostics sink for skipped declarations Today the analyzer logs a warning and silently drops any declaration it cannot fully model, and consumers have no programmatic way to observe that. That is acceptable for jextract's opt-out model, but a consumer with an opt-in model (e.g. JavaScriptKit's BridgeJS, where every @JS declaration was explicitly marked for export) must fail the build with a source-located error instead of quietly not exporting the API. Introduce SwiftExtractDiagnosticsSink, an optional observer supplied to SwiftAnalyzer/analyze(). Every skipped declaration is reported as a SwiftExtractDiagnostic carrying the affected syntax node, its source file path, the neutral message, and the underlying translation error, so consumers can render precise caret diagnostics or aggregate errors per run. The default (no sink) behavior and log output are unchanged. Also ship CollectingDiagnosticsSink, a trivial recording sink. --- .../SwiftExtract/SwiftAnalysisVisitor.swift | 77 ++++++++++----- Sources/SwiftExtract/SwiftAnalyzer.swift | 16 +++- .../SwiftExtractDiagnostics.swift | 55 +++++++++++ .../DiagnosticsSinkTests.swift | 96 +++++++++++++++++++ .../Support/TestAnalyze.swift | 6 +- 5 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 Sources/SwiftExtract/SwiftExtractDiagnostics.swift create mode 100644 Tests/SwiftExtractTests/DiagnosticsSinkTests.swift diff --git a/Sources/SwiftExtract/SwiftAnalysisVisitor.swift b/Sources/SwiftExtract/SwiftAnalysisVisitor.swift index 571dbe6ee..0f08b43df 100644 --- a/Sources/SwiftExtract/SwiftAnalysisVisitor.swift +++ b/Sources/SwiftExtract/SwiftAnalysisVisitor.swift @@ -79,15 +79,15 @@ final class SwiftAnalysisVisitor { break // TODO: Implement associated types case .initializerDecl(let node): - self.visit(initializerDecl: node, in: parent) + self.visit(initializerDecl: node, in: parent, sourceFilePath: sourceFilePath) case .functionDecl(let node): self.visit(functionDecl: node, in: parent, sourceFilePath: sourceFilePath) case .variableDecl(let node): self.visit(variableDecl: node, in: parent, sourceFilePath: sourceFilePath) case .subscriptDecl(let node): - self.visit(subscriptDecl: node, in: parent) + self.visit(subscriptDecl: node, in: parent, sourceFilePath: sourceFilePath) case .enumCaseDecl(let node): - self.visit(enumCaseDecl: node, in: parent) + self.visit(enumCaseDecl: node, in: parent, sourceFilePath: sourceFilePath) case .ifConfigDecl(let node): self.visit(ifConfigDecl: node, in: parent, sourceFilePath: sourceFilePath) default: @@ -205,10 +205,11 @@ final class SwiftAnalysisVisitor { lookupContext: analyzer.lookupContext, ) } catch { - self.log.warning( - self.makeMissingTypeMessage( - "Failed to import: '\(node.qualifiedNameForDebug)' in module '\(analyzer.swiftModuleName)'; \(error)" - ) + self.reportSkipped( + node, + name: "'\(node.qualifiedNameForDebug)'", + sourceFilePath: sourceFilePath, + error: error ) return } @@ -239,6 +240,7 @@ final class SwiftAnalysisVisitor { func visit( enumCaseDecl node: EnumCaseDeclSyntax, in typeContext: ExtractedNominalType?, + sourceFilePath: String, ) { guard let typeContext else { self.log.info("Enum case must be within a current type; \(node)") @@ -279,10 +281,11 @@ final class SwiftAnalysisVisitor { typeContext.cases.append(extractedCase) } } catch { - self.log.warning( - self.makeMissingTypeMessage( - "Failed to import: \(node.qualifiedNameForDebug) in module '\(analyzer.swiftModuleName)'; \(error)" - ) + self.reportSkipped( + node, + name: "\(node.qualifiedNameForDebug)", + sourceFilePath: sourceFilePath, + error: error ) } } @@ -326,10 +329,11 @@ final class SwiftAnalysisVisitor { ) } } catch { - self.log.warning( - self.makeMissingTypeMessage( - "Failed to import: \(node.qualifiedNameForDebug) in module '\(analyzer.swiftModuleName)'; \(error)" - ) + self.reportSkipped( + node, + name: "\(node.qualifiedNameForDebug)", + sourceFilePath: sourceFilePath, + error: error ) } } @@ -337,6 +341,7 @@ final class SwiftAnalysisVisitor { func visit( initializerDecl node: InitializerDeclSyntax, in typeContext: ExtractedNominalType?, + sourceFilePath: String, ) { guard let typeContext else { self.log.info("Initializer must be within a current type; \(node)") @@ -356,10 +361,11 @@ final class SwiftAnalysisVisitor { lookupContext: analyzer.lookupContext, ) } catch { - self.log.warning( - self.makeMissingTypeMessage( - "Failed to import: \(node.qualifiedNameForDebug) in module '\(analyzer.swiftModuleName)'; \(error)" - ) + self.reportSkipped( + node, + name: "\(node.qualifiedNameForDebug)", + sourceFilePath: sourceFilePath, + error: error ) return } @@ -377,6 +383,7 @@ final class SwiftAnalysisVisitor { private func visit( subscriptDecl node: SubscriptDeclSyntax, in typeContext: ExtractedNominalType?, + sourceFilePath: String, ) { guard node.shouldExtract(config: config, in: typeContext, decider: analyzer.extractDecider) else { return @@ -407,10 +414,11 @@ final class SwiftAnalysisVisitor { ) } } catch { - self.log.warning( - self.makeMissingTypeMessage( - "Failed to import: \(node.qualifiedNameForDebug) in module '\(analyzer.swiftModuleName)'; \(error)" - ) + self.reportSkipped( + node, + name: "\(node.qualifiedNameForDebug)", + sourceFilePath: sourceFilePath, + error: error ) } } @@ -725,6 +733,29 @@ final class SwiftAnalysisVisitor { } return "\(message). \(hint)" } + + /// Log a skipped declaration (as before) and report it to the configured + /// diagnostics sink, if any. + func reportSkipped( + _ node: some SyntaxProtocol, + name: String, + sourceFilePath: String, + error: any Error + ) { + let message = "Failed to import: \(name) in module '\(analyzer.swiftModuleName)'; \(error)" + self.log.warning(self.makeMissingTypeMessage(message)) + analyzer.diagnosticsSink?.emit( + SwiftExtractDiagnostic( + kind: .skippedDeclaration, + declarationName: name, + moduleName: analyzer.swiftModuleName, + message: message, + node: Syntax(node), + sourceFilePath: sourceFilePath, + underlyingError: error + ) + ) + } } extension DeclSyntaxProtocol where Self: WithModifiersSyntax & WithAttributesSyntax { diff --git a/Sources/SwiftExtract/SwiftAnalyzer.swift b/Sources/SwiftExtract/SwiftAnalyzer.swift index f43d3d4e8..e0c80b601 100644 --- a/Sources/SwiftExtract/SwiftAnalyzer.swift +++ b/Sources/SwiftExtract/SwiftAnalyzer.swift @@ -70,10 +70,15 @@ public final class SwiftAnalyzer { /// access-level-only baseline. package let extractDecider: any ExtractDecider + /// Receives an event for every declaration the analyzer skips; `nil` + /// keeps the default log-and-drop behavior only. + package let diagnosticsSink: (any SwiftExtractDiagnosticsSink)? + public init( config: any SwiftExtractConfiguration, moduleName: String? = nil, - extractDecider: any ExtractDecider + extractDecider: any ExtractDecider, + diagnosticsSink: (any SwiftExtractDiagnosticsSink)? = nil ) { guard let swiftModule = moduleName ?? config.swiftModule else { fatalError("Missing 'swiftModule' name.") // FIXME: can we make it required in config? but we shared config for many cases @@ -82,6 +87,7 @@ public final class SwiftAnalyzer { self.config = config self.swiftModuleName = swiftModule self.extractDecider = extractDecider + self.diagnosticsSink = diagnosticsSink if let staticBuildConfigPath = config.staticBuildConfigurationFile { do { @@ -270,10 +276,16 @@ extension SwiftAnalyzer { extractDecider: any ExtractDecider, config: (any SwiftExtractConfiguration)? = nil, sourceDependencies: SourceDependencies = SourceDependencies(), + diagnosticsSink: (any SwiftExtractDiagnosticsSink)? = nil, beforeProcessingDeferredExtensions hook: (SwiftAnalyzer) throws -> Void = { _ in } ) throws -> AnalysisResult { let effectiveConfig = config ?? DefaultSwiftExtractConfiguration(swiftModule: moduleName) - let analyzer = SwiftAnalyzer(config: effectiveConfig, moduleName: moduleName, extractDecider: extractDecider) + let analyzer = SwiftAnalyzer( + config: effectiveConfig, + moduleName: moduleName, + extractDecider: extractDecider, + diagnosticsSink: diagnosticsSink + ) analyzer.sourceDependencies = sourceDependencies for source in sources { analyzer.add(filePath: source.path, text: source.text) diff --git a/Sources/SwiftExtract/SwiftExtractDiagnostics.swift b/Sources/SwiftExtract/SwiftExtractDiagnostics.swift new file mode 100644 index 000000000..7979961e9 --- /dev/null +++ b/Sources/SwiftExtract/SwiftExtractDiagnostics.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntax + +/// A diagnostic reported by the analyzer. +public struct SwiftExtractDiagnostic { + public enum Kind { + /// The declaration was skipped entirely and is absent from the + /// `AnalysisResult`. + case skippedDeclaration + } + + public let kind: Kind + + /// Qualified name of the affected declaration, formatted for human-readable + /// output (e.g. `Greeter.greet(name:)`). + public let declarationName: String + + /// Name of the module being analyzed. + public let moduleName: String + + /// Neutral, consumer-independent description of what went wrong. Does not + /// include `SwiftExtractConfiguration.unresolvedTypeHint`, which is only + /// appended to the analyzer's own log output. + public let message: String + + /// The syntax node the event is anchored to. Consumers can derive precise + /// source locations from it: its root tree is the parsed source file. + public let node: Syntax + + /// Path of the source file containing `node`, as supplied to the analyzer. + public let sourceFilePath: String + + /// The error that caused the declaration to be diagnosed, when one was thrown. + public let underlyingError: (any Error)? +} + +/// Receives diagnostic events during analysis. +/// +/// Supplying a sink does not suppress the analyzer's log output. +public protocol SwiftExtractDiagnosticsSink { + func emit(_ diagnostic: SwiftExtractDiagnostic) +} diff --git a/Tests/SwiftExtractTests/DiagnosticsSinkTests.swift b/Tests/SwiftExtractTests/DiagnosticsSinkTests.swift new file mode 100644 index 000000000..c11005a55 --- /dev/null +++ b/Tests/SwiftExtractTests/DiagnosticsSinkTests.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftExtract +import SwiftSyntax +import Testing + +@Suite("Diagnostics sink") +struct DiagnosticsSinkSuite { + + @Test func skippedDeclarationsAreReportedWithNodeAndFile() throws { + let sink = CollectingDiagnosticsSink() + let result = try analyze( + sources: [ + ( + "/fake/Tank.swift", + """ + public func fillTank(_ water: Water) {} + public func drainTank() {} + """ + ), + ( + "/fake/Fish.swift", + """ + public class Fish { + public init(species: Species) {} + public var home: Aquarium { fatalError() } + } + """ + ), + ], + moduleName: "Aquarium", + diagnosticsSink: sink + ) + + // The resolvable declarations still extract. + #expect(result.extractedGlobalFuncs.map(\.name) == ["drainTank"]) + #expect(result.extractedTypes["Fish"] != nil) + + // One event per skipped declaration, anchored to its node and file. + #expect(sink.diagnostics.count == 3) + for diagnostic in sink.diagnostics { + #expect(diagnostic.kind == .skippedDeclaration) + #expect(diagnostic.moduleName == "Aquarium") + #expect(diagnostic.underlyingError != nil) + } + + let byName = Dictionary( + uniqueKeysWithValues: sink.diagnostics.map { ($0.declarationName, $0) } + ) + let fillTank = try #require(byName["'fillTank(_:)'"]) + #expect(fillTank.sourceFilePath == "/fake/Tank.swift") + #expect(fillTank.node.is(FunctionDeclSyntax.self)) + #expect(fillTank.message.contains("Failed to import")) + + let initializer = try #require(byName["Fish.init(species:)"]) + #expect(initializer.sourceFilePath == "/fake/Fish.swift") + #expect(initializer.node.is(InitializerDeclSyntax.self)) + + let variable = try #require(byName["Fish.home"]) + #expect(variable.sourceFilePath == "/fake/Fish.swift") + #expect(variable.node.is(VariableDeclSyntax.self)) + } + + @Test func noEventsWhenEverythingExtracts() throws { + let sink = CollectingDiagnosticsSink() + _ = try analyze( + sources: [("/fake/Source.swift", "public func swim(distance: Int) {}")], + moduleName: "Aquarium", + diagnosticsSink: sink + ) + #expect(sink.diagnostics.isEmpty) + } +} + +/// A simple sink that records every event it receives, in order. +final class CollectingDiagnosticsSink: SwiftExtractDiagnosticsSink { + var diagnostics: [SwiftExtractDiagnostic] = [] + + init() {} + + func emit(_ diagnostic: SwiftExtractDiagnostic) { + diagnostics.append(diagnostic) + } +} diff --git a/Tests/SwiftExtractTests/Support/TestAnalyze.swift b/Tests/SwiftExtractTests/Support/TestAnalyze.swift index 86dcb0949..2188e18d3 100644 --- a/Tests/SwiftExtractTests/Support/TestAnalyze.swift +++ b/Tests/SwiftExtractTests/Support/TestAnalyze.swift @@ -27,7 +27,8 @@ func analyze( sources: [(path: String, text: String)], moduleName: String, config: (any SwiftExtractConfiguration)? = nil, - sourceDependencies: SourceDependencies = SourceDependencies() + sourceDependencies: SourceDependencies = SourceDependencies(), + diagnosticsSink: (any SwiftExtractDiagnosticsSink)? = nil ) throws -> AnalysisResult { let effectiveConfig = config ?? DefaultSwiftExtractConfiguration(swiftModule: moduleName) return try SwiftAnalyzer.analyze( @@ -35,6 +36,7 @@ func analyze( moduleName: moduleName, extractDecider: DefaultAccessLevelExtractDecider(accessLevel: effectiveConfig.effectiveMinimumInputAccessLevelMode), config: effectiveConfig, - sourceDependencies: sourceDependencies + sourceDependencies: sourceDependencies, + diagnosticsSink: diagnosticsSink ) }