diff --git a/Sources/SwiftExtract/ExtractedDecls+Attributes.swift b/Sources/SwiftExtract/ExtractedDecls+Attributes.swift new file mode 100644 index 000000000..8f28fb6e5 --- /dev/null +++ b/Sources/SwiftExtract/ExtractedDecls+Attributes.swift @@ -0,0 +1,65 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +extension ExtractedNominalType { + public var attributeList: AttributeListSyntax { + swiftNominal.syntax.attributes + } + + public func attribute(named name: String) -> AttributeSyntax? { + Self.first(attribute: name, in: attributeList) + } + + static func first(attribute name: String, in attributes: AttributeListSyntax) -> AttributeSyntax? { + for element in attributes { + guard let attr = element.as(AttributeSyntax.self), + let attrName = attr.attributeName.as(IdentifierTypeSyntax.self)?.name.text, + attrName == name + else { + continue + } + return attr + } + return nil + } +} + +extension ExtractedFunc { + public var attributeList: AttributeListSyntax? { + if let n = swiftDecl.as(FunctionDeclSyntax.self) { return n.attributes } + if let n = swiftDecl.as(InitializerDeclSyntax.self) { return n.attributes } + if let n = swiftDecl.as(VariableDeclSyntax.self) { return n.attributes } + if let n = swiftDecl.as(SubscriptDeclSyntax.self) { return n.attributes } + if let n = swiftDecl.as(EnumCaseDeclSyntax.self) { return n.attributes } + return nil + } + + public func attribute(named name: String) -> AttributeSyntax? { + guard let list = attributeList else { return nil } + return ExtractedNominalType.first(attribute: name, in: list) + } +} + +extension ExtractedEnumCase { + public var attributeList: AttributeListSyntax? { + swiftDecl.as(EnumCaseDeclSyntax.self)?.attributes + } + + public func attribute(named name: String) -> AttributeSyntax? { + guard let list = attributeList else { return nil } + return ExtractedNominalType.first(attribute: name, in: list) + } +} diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift index e8c4b87d1..a7ad802c6 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift @@ -27,6 +27,7 @@ public struct SwiftFunctionSignature: Equatable { public var parameters: [SwiftParameter] public var result: SwiftResult public var effectSpecifiers: [SwiftEffectSpecifier] + public var thrownTypedError: SwiftType? public var genericParameters: [SwiftGenericParameterDeclaration] public var genericRequirements: [SwiftGenericRequirement] @@ -38,6 +39,10 @@ public struct SwiftFunctionSignature: Equatable { effectSpecifiers.contains(.throws) } + public var isTypedThrowing: Bool { + thrownTypedError != nil + } + /// Whether any parameter is variadic (`T...`). public var hasVariadicParams: Bool { parameters.contains(where: \.isVariadic) @@ -53,6 +58,7 @@ public struct SwiftFunctionSignature: Equatable { parameters: [SwiftParameter], result: SwiftResult, effectSpecifiers: [SwiftEffectSpecifier], + thrownTypedError: SwiftType? = nil, genericParameters: [SwiftGenericParameterDeclaration], genericRequirements: [SwiftGenericRequirement] ) { @@ -60,6 +66,7 @@ public struct SwiftFunctionSignature: Equatable { self.parameters = parameters self.result = result self.effectSpecifiers = effectSpecifiers + self.thrownTypedError = thrownTypedError self.genericParameters = genericParameters self.genericRequirements = genericRequirements } @@ -120,7 +127,7 @@ extension SwiftFunctionSignature { whereClause: node.genericWhereClause, lookupContext: lookupContext ) - let (parameters, effectSpecifiers) = try Self.translateFunctionSignature( + let (parameters, effectSpecifiers, thrownTypedError) = try Self.translateFunctionSignature( node.signature, lookupContext: lookupContext ) @@ -138,6 +145,7 @@ extension SwiftFunctionSignature { parameters: parameters, result: SwiftResult(convention: .direct, type: type), effectSpecifiers: effectSpecifiers, + thrownTypedError: thrownTypedError, genericParameters: genericParams, genericRequirements: genericRequirements ) @@ -205,8 +213,8 @@ extension SwiftFunctionSignature { selfParameter = nil } - // Translate the parameters. - let (parameters, effectSpecifiers) = try Self.translateFunctionSignature( + // Translate the function signature + let (parameters, effectSpecifiers, thrownTypedError) = try Self.translateFunctionSignature( node.signature, lookupContext: lookupContext ) @@ -227,6 +235,7 @@ extension SwiftFunctionSignature { parameters: parameters, result: result, effectSpecifiers: effectSpecifiers, + thrownTypedError: thrownTypedError, genericParameters: genericParams, genericRequirements: genericRequirements ) @@ -289,11 +298,16 @@ extension SwiftFunctionSignature { } /// Translate the function signature, returning the list of translated - /// parameters and effect specifiers. + /// parameters, its effect specifiers, and the error type of a typed + /// `throws(E)` clause if present. public static func translateFunctionSignature( _ signature: FunctionSignatureSyntax, lookupContext: SwiftTypeLookupContext - ) throws -> ([SwiftParameter], [SwiftEffectSpecifier]) { + ) throws -> ( + parameters: [SwiftParameter], + effectSpecifiers: [SwiftEffectSpecifier], + thrownTypedError: SwiftType? + ) { var effectSpecifiers = [SwiftEffectSpecifier]() if signature.effectSpecifiers?.throwsClause != nil { effectSpecifiers.append(.throws) @@ -302,11 +316,16 @@ extension SwiftFunctionSignature { effectSpecifiers.append(.async) } + let thrownTypedError = SwiftType.thrownTypedError( + from: signature.effectSpecifiers?.throwsClause, + lookupContext: lookupContext + ) + let parameters = try signature.parameterClause.parameters.map { param in try SwiftParameter(param, lookupContext: lookupContext) } - return (parameters, effectSpecifiers) + return (parameters, effectSpecifiers, thrownTypedError) } public init( @@ -331,21 +350,22 @@ extension SwiftFunctionSignature { } let valueType = try SwiftType(varTypeNode, lookupContext: lookupContext) - var effectSpecifiers: [SwiftEffectSpecifier]? = nil + var accessorEffects: AccessorEffects? = nil switch binding.accessorBlock?.accessors { case .getter(let getter): if let getter = getter.as(AccessorDeclSyntax.self) { - effectSpecifiers = try Self.effectSpecifiers(from: getter) + accessorEffects = Self.translateEffectSpecifiers(from: getter, lookupContext: lookupContext) } case .accessors(let accessors): if let getter = accessors.first(where: { $0.accessorSpecifier.tokenKind == .keyword(.get) }) { - effectSpecifiers = try Self.effectSpecifiers(from: getter) + accessorEffects = Self.translateEffectSpecifiers(from: getter, lookupContext: lookupContext) } default: break } - self.effectSpecifiers = effectSpecifiers ?? [] + self.effectSpecifiers = accessorEffects?.effectSpecifiers ?? [] + self.thrownTypedError = accessorEffects?.thrownTypedError if isSet { self.parameters = [ @@ -388,21 +408,22 @@ extension SwiftFunctionSignature { return p } - var effectSpecifiers: [SwiftEffectSpecifier]? = nil + var accessorEffects: AccessorEffects? = nil switch subscriptNode.accessorBlock?.accessors { case .getter(let getter): if let getter = getter.as(AccessorDeclSyntax.self) { - effectSpecifiers = try Self.effectSpecifiers(from: getter) + accessorEffects = Self.translateEffectSpecifiers(from: getter, lookupContext: lookupContext) } case .accessors(let accessors): if let getter = accessors.first(where: { $0.accessorSpecifier.tokenKind == .keyword(.get) }) { - effectSpecifiers = try Self.effectSpecifiers(from: getter) + accessorEffects = Self.translateEffectSpecifiers(from: getter, lookupContext: lookupContext) } default: break } - self.effectSpecifiers = effectSpecifiers ?? [] + self.effectSpecifiers = accessorEffects?.effectSpecifiers ?? [] + self.thrownTypedError = accessorEffects?.thrownTypedError if isSet { nodeParameters.append(SwiftParameter(convention: .byValue, parameterName: "newValue", type: valueType)) @@ -416,7 +437,15 @@ extension SwiftFunctionSignature { self.genericRequirements = [] } - private static func effectSpecifiers(from decl: AccessorDeclSyntax) throws -> [SwiftEffectSpecifier] { + struct AccessorEffects { + var effectSpecifiers: [SwiftEffectSpecifier] = [] + var thrownTypedError: SwiftType? = nil + } + + private static func translateEffectSpecifiers( + from decl: AccessorDeclSyntax, + lookupContext: SwiftTypeLookupContext + ) -> AccessorEffects { var effectSpecifiers = [SwiftEffectSpecifier]() if decl.effectSpecifiers?.throwsClause != nil { effectSpecifiers.append(.throws) @@ -424,7 +453,13 @@ extension SwiftFunctionSignature { if decl.effectSpecifiers?.asyncSpecifier != nil { effectSpecifiers.append(.async) } - return effectSpecifiers + + let thrownTypedError = SwiftType.thrownTypedError( + from: decl.effectSpecifiers?.throwsClause, + lookupContext: lookupContext + ) + + return AccessorEffects(effectSpecifiers: effectSpecifiers, thrownTypedError: thrownTypedError) } private static func variableSelfParameter( diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift index e94ad4e32..a7e8f43c5 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionType.swift @@ -27,11 +27,11 @@ public struct SwiftFunctionType: Equatable { 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 var thrownTypedError: SwiftType? = nil public init( convention: Convention, @@ -95,9 +95,10 @@ extension SwiftFunctionType { } if let throwsClause = node.effectSpecifiers?.throwsClause { effectSpecifiers.append(.throws) - if let errorTypeNode = throwsClause.type { - self.thrownTypedError = try? SwiftType(errorTypeNode, lookupContext: lookupContext) - } + self.thrownTypedError = SwiftType.thrownTypedError( + from: throwsClause, + lookupContext: lookupContext + ) } self.effectSpecifiers = effectSpecifiers } diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift index a4e21518e..0cb2fedf0 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftType.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftType.swift @@ -339,6 +339,18 @@ extension SwiftNominalType { } } +extension SwiftType { + public static func thrownTypedError( + from throwsClause: ThrowsClauseSyntax?, + lookupContext: SwiftTypeLookupContext + ) -> SwiftType? { + guard let errorTypeNode = throwsClause?.type else { + return nil + } + return try? SwiftType(errorTypeNode, lookupContext: lookupContext) + } +} + extension SwiftType { public init(_ type: TypeSyntax, lookupContext: SwiftTypeLookupContext) throws { var knownTypes: SwiftKnownTypes { diff --git a/Tests/SwiftExtractTests/AnalysisResultTests.swift b/Tests/SwiftExtractTests/AnalysisResultTests.swift index 19e042ce5..0ac1f5507 100644 --- a/Tests/SwiftExtractTests/AnalysisResultTests.swift +++ b/Tests/SwiftExtractTests/AnalysisResultTests.swift @@ -352,6 +352,119 @@ struct AnalysisResultSuite { #expect(both.functionSignature.effectSpecifiers.contains(.throws)) } + @Test + func typedThrows() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public struct FishTankError: Error {} + public func untyped() throws {} + public func typed() throws(FishTankError) {} + public func typedAsync() async throws(FishTankError) {} + public func unresolvable() throws(NoSuchError) {} + """ + ) + ], + moduleName: "Aquarium" + ) + + let byName = Dictionary(uniqueKeysWithValues: result.extractedGlobalFuncs.map { ($0.name, $0) }) + + let untyped = try #require(byName["untyped"]) + #expect(untyped.functionSignature.isThrowing) + #expect(!untyped.functionSignature.isTypedThrowing) + #expect(untyped.functionSignature.thrownTypedError == nil) + + let typed = try #require(byName["typed"]) + #expect(typed.functionSignature.isThrowing) + #expect(typed.functionSignature.isTypedThrowing) + #expect(typed.functionSignature.thrownTypedError?.description == "FishTankError") + + let typedAsync = try #require(byName["typedAsync"]) + #expect(typedAsync.functionSignature.isAsync) + #expect(typedAsync.functionSignature.thrownTypedError?.description == "FishTankError") + + // An unresolvable error type must not skip the entire decl + let unresolvable = try #require(byName["unresolvable"]) + #expect(unresolvable.functionSignature.isThrowing) + #expect(unresolvable.functionSignature.thrownTypedError == nil) + } + + @Test + func typedThrowsOnInitializers() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public struct FishTankError: Error {} + public struct FishTank { + public init(capacity: Int) throws(FishTankError) {} + } + """ + ) + ], + moduleName: "Aquarium" + ) + + let fishTank = try #require(result.extractedTypes["FishTank"]) + let initializer = try #require(fishTank.initializers.first) + #expect(initializer.functionSignature.isThrowing) + #expect(initializer.functionSignature.thrownTypedError?.description == "FishTankError") + } + + @Test + func typedThrowsOnPropertyGetter() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public struct FishTankError: Error {} + public struct FishTank { + public var capacity: Int { + get throws(FishTankError) { 0 } + } + } + """ + ) + ], + moduleName: "Aquarium" + ) + + let fishTank = try #require(result.extractedTypes["FishTank"]) + let getter = try #require(fishTank.variables.first { $0.apiKind == .getter }) + #expect(getter.functionSignature.isThrowing) + #expect(getter.functionSignature.thrownTypedError?.description == "FishTankError") + } + + @Test + func typedThrowsOnSubscriptGetter() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public struct FishTankError: Error {} + public struct FishTank { + public subscript(index: Int) -> Int { + get throws(FishTankError) { 0 } + } + } + """ + ) + ], + moduleName: "Aquarium" + ) + + let fishTank = try #require(result.extractedTypes["FishTank"]) + let subscriptGetter = try #require(fishTank.variables.first { $0.apiKind == .subscriptGetter }) + #expect(subscriptGetter.functionSignature.isThrowing) + #expect(subscriptGetter.functionSignature.thrownTypedError?.description == "FishTankError") + } + // ==== ----------------------------------------------------------------------- // MARK: Access-level filtering diff --git a/Tests/SwiftExtractTests/AttributesTests.swift b/Tests/SwiftExtractTests/AttributesTests.swift new file mode 100644 index 000000000..088e1fed9 --- /dev/null +++ b/Tests/SwiftExtractTests/AttributesTests.swift @@ -0,0 +1,65 @@ +//===----------------------------------------------------------------------===// +// +// 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 +struct AttributesSuite { + + @Test + func typeAttribute() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + @SomeAttr public struct Marked {} + public struct Plain {} + """ + ) + ], + moduleName: "Test" + ) + + let marked = try #require(result.extractedTypes.values.first { $0.swiftNominal.name == "Marked" }) + let plain = try #require(result.extractedTypes.values.first { $0.swiftNominal.name == "Plain" }) + + #expect(marked.attribute(named: "SomeAttr") != nil) + #expect(plain.attribute(named: "SomeAttr") == nil) + } + + @Test + func funcAttribute() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + @SomeAttr("customName", namespace: "Utils") public func greet() {} + """ + ) + ], + moduleName: "Test" + ) + + let fn = try #require(result.extractedGlobalFuncs.first { $0.name == "greet" }) + let attr = try #require(fn.attribute(named: "SomeAttr")) + let text = attr.trimmedDescription + #expect(text.contains("\"customName\"")) + #expect(text.contains("namespace: \"Utils\"")) + } + +} diff --git a/Tests/SwiftExtractTests/FunctionTypeEffectSpecifierTests.swift b/Tests/SwiftExtractTests/FunctionTypeEffectsTests.swift similarity index 95% rename from Tests/SwiftExtractTests/FunctionTypeEffectSpecifierTests.swift rename to Tests/SwiftExtractTests/FunctionTypeEffectsTests.swift index fb27f32e1..f6bc3d2ff 100644 --- a/Tests/SwiftExtractTests/FunctionTypeEffectSpecifierTests.swift +++ b/Tests/SwiftExtractTests/FunctionTypeEffectsTests.swift @@ -15,7 +15,7 @@ import SwiftExtract import Testing -@Suite("Function type effect specifiers") +@Suite struct FunctionTypeEffectSpecifierSuite { private func closureParameterType(_ source: String) throws -> SwiftFunctionType { @@ -108,7 +108,14 @@ struct FunctionTypeEffectSpecifierSuite { @Test func effectsOnReturnedClosureAreRecorded() throws { let result = try analyze( - sources: [("/fake/Source.swift", "public func get() -> () async -> Void { fatalError() }")], + sources: [ + ( + "/fake/Source.swift", + """ + public func get() -> () async -> Void { fatalError() } + """ + ) + ], moduleName: "Test" )