From 285e3d5aa5ed37967e9b84ef05647f5e874ce1c4 Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Wed, 19 Aug 2026 08:18:29 +0900 Subject: [PATCH] [SwiftExtract] record async/throws specifiers Handle throws and async explicitly so it is easier to consume by source generators looking at the analysis. Also prepare for better typed throws handling --- .../SwiftTypes/SwiftFunctionType.swift | 37 ++++- .../FunctionTypeEffectSpecifierTests.swift | 129 ++++++++++++++++++ 2 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 Tests/SwiftExtractTests/FunctionTypeEffectSpecifierTests.swift diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift index b127e6fb7..e94ad4e32 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift @@ -25,16 +25,28 @@ public struct SwiftFunctionType: Equatable { public var resultType: SwiftType public var isEscaping: Bool = false + public var effectSpecifiers: [SwiftEffectSpecifier] = [] + + public var thrownTypedError: SwiftType? = nil + + public var isAsync: Bool { effectSpecifiers.contains(.async) } + public var isThrowing: Bool { effectSpecifiers.contains(.throws) } + public var isTypedThrowing: Bool { thrownTypedError != nil } + public init( convention: Convention, parameters: [SwiftParameter], resultType: SwiftType, - isEscaping: Bool = false + isEscaping: Bool = false, + effectSpecifiers: [SwiftEffectSpecifier] = [], + thrownTypedError: SwiftType? = nil ) { self.convention = convention self.parameters = parameters self.resultType = resultType self.isEscaping = isEscaping + self.effectSpecifiers = effectSpecifiers + self.thrownTypedError = thrownTypedError } } @@ -47,7 +59,14 @@ extension SwiftFunctionType: CustomStringConvertible { case .swift: "" } let escapingPrefix = isEscaping ? "@escaping " : "" - return "\(escapingPrefix)\(conventionPrefix)(\(parameterString)) -> \(resultType.description)" + let throwsString = + switch (isThrowing, thrownTypedError) { + case (true, .some(let errorType)): " throws(\(errorType.description))" + case (true, .none): " throws" + case (false, _): "" + } + let effectsSuffix = (isAsync ? " async" : "") + throwsString + return "\(escapingPrefix)\(conventionPrefix)(\(parameterString))\(effectsSuffix) -> \(resultType.description)" } } @@ -70,12 +89,16 @@ extension SwiftFunctionType { self.resultType = try SwiftType(node.returnClause.type, lookupContext: lookupContext) - // check for effect specifiers - if let throwsClause = node.effectSpecifiers?.throwsClause { - throw SwiftFunctionTranslationError.throws(throwsClause) + var effectSpecifiers: [SwiftEffectSpecifier] = [] + if node.effectSpecifiers?.asyncSpecifier != nil { + effectSpecifiers.append(.async) } - if let asyncSpecifier = node.effectSpecifiers?.asyncSpecifier { - throw SwiftFunctionTranslationError.async(asyncSpecifier) + if let throwsClause = node.effectSpecifiers?.throwsClause { + effectSpecifiers.append(.throws) + if let errorTypeNode = throwsClause.type { + self.thrownTypedError = try? SwiftType(errorTypeNode, lookupContext: lookupContext) + } } + self.effectSpecifiers = effectSpecifiers } } diff --git a/Tests/SwiftExtractTests/FunctionTypeEffectSpecifierTests.swift b/Tests/SwiftExtractTests/FunctionTypeEffectSpecifierTests.swift new file mode 100644 index 000000000..fb27f32e1 --- /dev/null +++ b/Tests/SwiftExtractTests/FunctionTypeEffectSpecifierTests.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing + +@Suite("Function type effect specifiers") +struct FunctionTypeEffectSpecifierSuite { + + private func closureParameterType(_ source: String) throws -> SwiftFunctionType { + let result = try analyze( + sources: [("/fake/Source.swift", source)], + moduleName: "Test" + ) + + let fn = try #require(result.extractedGlobalFuncs.first { $0.name == "take" }) + guard case .function(let fnType) = fn.functionSignature.parameters[0].type else { + throw TestError("expected .function parameter, got \(fn.functionSignature.parameters[0].type)") + } + return fnType + } + + @Test + func asyncClosureRecordsAsync() throws { + let fnType = try closureParameterType("public func take(_ cb: () async -> Void) {}") + + #expect(fnType.effectSpecifiers == [.async]) + #expect(fnType.isAsync) + #expect(!fnType.isThrowing) + } + + @Test + func throwingClosureRecordsThrows() throws { + let fnType = try closureParameterType("public func take(_ cb: () throws -> Void) {}") + + #expect(fnType.effectSpecifiers == [.throws]) + #expect(!fnType.isAsync) + #expect(fnType.isThrowing) + #expect(!fnType.isTypedThrowing) + #expect(fnType.thrownTypedError == nil) + } + + @Test + func typedThrowsClosureRecordsThrownErrorType() throws { + let fnType = try closureParameterType( + """ + public struct FishTankError: Error {} + public func take(_ cb: () throws(FishTankError) -> Void) {} + """ + ) + + #expect(fnType.effectSpecifiers == [.throws]) + #expect(fnType.isThrowing) + #expect(fnType.isTypedThrowing) + #expect(fnType.thrownTypedError?.description == "FishTankError") + } + + @Test + func unresolvableThrownErrorTypeStillRecordsThrows() throws { + let fnType = try closureParameterType("public func take(_ cb: () throws(NoSuchError) -> Void) {}") + + #expect(fnType.effectSpecifiers == [.throws]) + #expect(fnType.isThrowing) + #expect(fnType.thrownTypedError == nil) + } + + @Test + func asyncThrowingClosureRecordsBoth() throws { + let fnType = try closureParameterType("public func take(_ cb: () async throws -> Void) {}") + + #expect(fnType.effectSpecifiers == [.async, .throws]) + #expect(fnType.isAsync) + #expect(fnType.isThrowing) + } + + @Test + func descriptionRendersEffectSpecifiers() throws { + let fnType = try closureParameterType( + "public func take(_ cb: @escaping (Int) async throws -> Void) {}" + ) + + #expect(fnType.description == "@escaping (Int) async throws -> Void") + } + + @Test + func descriptionRendersThrownErrorType() throws { + let fnType = try closureParameterType( + """ + public struct FishTankError: Error {} + public func take(_ cb: @escaping (Int) async throws(FishTankError) -> Void) {} + """ + ) + + #expect(fnType.description == "@escaping (Int) async throws(FishTankError) -> Void") + } + + @Test + func effectsOnReturnedClosureAreRecorded() throws { + let result = try analyze( + sources: [("/fake/Source.swift", "public func get() -> () async -> Void { fatalError() }")], + moduleName: "Test" + ) + + let fn = try #require(result.extractedGlobalFuncs.first { $0.name == "get" }) + guard case .function(let fnType) = fn.functionSignature.result.type else { + Issue.record("expected .function result, got \(fn.functionSignature.result.type)") + return + } + #expect(fnType.effectSpecifiers == [.async]) + } +} + +private struct TestError: Error, CustomStringConvertible { + let description: String + init(_ description: String) { + self.description = description + } +}