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