Skip to content

Commit 673c817

Browse files
authored
[SwiftExtract] record async/throws specifiers (#893)
1 parent ec6ae15 commit 673c817

2 files changed

Lines changed: 159 additions & 7 deletions

File tree

Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,28 @@ public struct SwiftFunctionType: Equatable {
2525
public var resultType: SwiftType
2626
public var isEscaping: Bool = false
2727

28+
public var effectSpecifiers: [SwiftEffectSpecifier] = []
29+
30+
public var thrownTypedError: SwiftType? = nil
31+
32+
public var isAsync: Bool { effectSpecifiers.contains(.async) }
33+
public var isThrowing: Bool { effectSpecifiers.contains(.throws) }
34+
public var isTypedThrowing: Bool { thrownTypedError != nil }
35+
2836
public init(
2937
convention: Convention,
3038
parameters: [SwiftParameter],
3139
resultType: SwiftType,
32-
isEscaping: Bool = false
40+
isEscaping: Bool = false,
41+
effectSpecifiers: [SwiftEffectSpecifier] = [],
42+
thrownTypedError: SwiftType? = nil
3343
) {
3444
self.convention = convention
3545
self.parameters = parameters
3646
self.resultType = resultType
3747
self.isEscaping = isEscaping
48+
self.effectSpecifiers = effectSpecifiers
49+
self.thrownTypedError = thrownTypedError
3850
}
3951
}
4052

@@ -47,7 +59,14 @@ extension SwiftFunctionType: CustomStringConvertible {
4759
case .swift: ""
4860
}
4961
let escapingPrefix = isEscaping ? "@escaping " : ""
50-
return "\(escapingPrefix)\(conventionPrefix)(\(parameterString)) -> \(resultType.description)"
62+
let throwsString =
63+
switch (isThrowing, thrownTypedError) {
64+
case (true, .some(let errorType)): " throws(\(errorType.description))"
65+
case (true, .none): " throws"
66+
case (false, _): ""
67+
}
68+
let effectsSuffix = (isAsync ? " async" : "") + throwsString
69+
return "\(escapingPrefix)\(conventionPrefix)(\(parameterString))\(effectsSuffix) -> \(resultType.description)"
5170
}
5271
}
5372

@@ -70,12 +89,16 @@ extension SwiftFunctionType {
7089

7190
self.resultType = try SwiftType(node.returnClause.type, lookupContext: lookupContext)
7291

73-
// check for effect specifiers
74-
if let throwsClause = node.effectSpecifiers?.throwsClause {
75-
throw SwiftFunctionTranslationError.throws(throwsClause)
92+
var effectSpecifiers: [SwiftEffectSpecifier] = []
93+
if node.effectSpecifiers?.asyncSpecifier != nil {
94+
effectSpecifiers.append(.async)
7695
}
77-
if let asyncSpecifier = node.effectSpecifiers?.asyncSpecifier {
78-
throw SwiftFunctionTranslationError.async(asyncSpecifier)
96+
if let throwsClause = node.effectSpecifiers?.throwsClause {
97+
effectSpecifiers.append(.throws)
98+
if let errorTypeNode = throwsClause.type {
99+
self.thrownTypedError = try? SwiftType(errorTypeNode, lookupContext: lookupContext)
100+
}
79101
}
102+
self.effectSpecifiers = effectSpecifiers
80103
}
81104
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import SwiftExtract
16+
import Testing
17+
18+
@Suite("Function type effect specifiers")
19+
struct FunctionTypeEffectSpecifierSuite {
20+
21+
private func closureParameterType(_ source: String) throws -> SwiftFunctionType {
22+
let result = try analyze(
23+
sources: [("/fake/Source.swift", source)],
24+
moduleName: "Test"
25+
)
26+
27+
let fn = try #require(result.extractedGlobalFuncs.first { $0.name == "take" })
28+
guard case .function(let fnType) = fn.functionSignature.parameters[0].type else {
29+
throw TestError("expected .function parameter, got \(fn.functionSignature.parameters[0].type)")
30+
}
31+
return fnType
32+
}
33+
34+
@Test
35+
func asyncClosureRecordsAsync() throws {
36+
let fnType = try closureParameterType("public func take(_ cb: () async -> Void) {}")
37+
38+
#expect(fnType.effectSpecifiers == [.async])
39+
#expect(fnType.isAsync)
40+
#expect(!fnType.isThrowing)
41+
}
42+
43+
@Test
44+
func throwingClosureRecordsThrows() throws {
45+
let fnType = try closureParameterType("public func take(_ cb: () throws -> Void) {}")
46+
47+
#expect(fnType.effectSpecifiers == [.throws])
48+
#expect(!fnType.isAsync)
49+
#expect(fnType.isThrowing)
50+
#expect(!fnType.isTypedThrowing)
51+
#expect(fnType.thrownTypedError == nil)
52+
}
53+
54+
@Test
55+
func typedThrowsClosureRecordsThrownErrorType() throws {
56+
let fnType = try closureParameterType(
57+
"""
58+
public struct FishTankError: Error {}
59+
public func take(_ cb: () throws(FishTankError) -> Void) {}
60+
"""
61+
)
62+
63+
#expect(fnType.effectSpecifiers == [.throws])
64+
#expect(fnType.isThrowing)
65+
#expect(fnType.isTypedThrowing)
66+
#expect(fnType.thrownTypedError?.description == "FishTankError")
67+
}
68+
69+
@Test
70+
func unresolvableThrownErrorTypeStillRecordsThrows() throws {
71+
let fnType = try closureParameterType("public func take(_ cb: () throws(NoSuchError) -> Void) {}")
72+
73+
#expect(fnType.effectSpecifiers == [.throws])
74+
#expect(fnType.isThrowing)
75+
#expect(fnType.thrownTypedError == nil)
76+
}
77+
78+
@Test
79+
func asyncThrowingClosureRecordsBoth() throws {
80+
let fnType = try closureParameterType("public func take(_ cb: () async throws -> Void) {}")
81+
82+
#expect(fnType.effectSpecifiers == [.async, .throws])
83+
#expect(fnType.isAsync)
84+
#expect(fnType.isThrowing)
85+
}
86+
87+
@Test
88+
func descriptionRendersEffectSpecifiers() throws {
89+
let fnType = try closureParameterType(
90+
"public func take(_ cb: @escaping (Int) async throws -> Void) {}"
91+
)
92+
93+
#expect(fnType.description == "@escaping (Int) async throws -> Void")
94+
}
95+
96+
@Test
97+
func descriptionRendersThrownErrorType() throws {
98+
let fnType = try closureParameterType(
99+
"""
100+
public struct FishTankError: Error {}
101+
public func take(_ cb: @escaping (Int) async throws(FishTankError) -> Void) {}
102+
"""
103+
)
104+
105+
#expect(fnType.description == "@escaping (Int) async throws(FishTankError) -> Void")
106+
}
107+
108+
@Test
109+
func effectsOnReturnedClosureAreRecorded() throws {
110+
let result = try analyze(
111+
sources: [("/fake/Source.swift", "public func get() -> () async -> Void { fatalError() }")],
112+
moduleName: "Test"
113+
)
114+
115+
let fn = try #require(result.extractedGlobalFuncs.first { $0.name == "get" })
116+
guard case .function(let fnType) = fn.functionSignature.result.type else {
117+
Issue.record("expected .function result, got \(fn.functionSignature.result.type)")
118+
return
119+
}
120+
#expect(fnType.effectSpecifiers == [.async])
121+
}
122+
}
123+
124+
private struct TestError: Error, CustomStringConvertible {
125+
let description: String
126+
init(_ description: String) {
127+
self.description = description
128+
}
129+
}

0 commit comments

Comments
 (0)