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
77 changes: 54 additions & 23 deletions Sources/SwiftExtract/SwiftAnalysisVisitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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
)
}
}
Expand Down Expand Up @@ -326,17 +329,19 @@ 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
)
}
}

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)")
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 14 additions & 2 deletions Sources/SwiftExtract/SwiftAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions Sources/SwiftExtract/SwiftExtractDiagnostics.swift
Original file line number Diff line number Diff line change
@@ -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)
}
96 changes: 96 additions & 0 deletions Tests/SwiftExtractTests/DiagnosticsSinkTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 4 additions & 2 deletions Tests/SwiftExtractTests/Support/TestAnalyze.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ 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(
sources: sources,
moduleName: moduleName,
extractDecider: DefaultAccessLevelExtractDecider(accessLevel: effectiveConfig.effectiveMinimumInputAccessLevelMode),
config: effectiveConfig,
sourceDependencies: sourceDependencies
sourceDependencies: sourceDependencies,
diagnosticsSink: diagnosticsSink
)
}
Loading