diff --git a/Sources/SafeDICore/Models/Initializer.swift b/Sources/SafeDICore/Models/Initializer.swift index 92c78c0b..859349ba 100644 --- a/Sources/SafeDICore/Models/Initializer.swift +++ b/Sources/SafeDICore/Models/Initializer.swift @@ -305,7 +305,8 @@ extension TypeDescription { .dictionary, .tuple, .closure, - .unknown: + .unknown, + .void: self == argumentTypeDescription && argumentSpecifier == nil && (argumentAttributes ?? []).contains("escaping") @@ -326,7 +327,8 @@ extension TypeDescription { .array, .dictionary, .tuple, - .unknown: + .unknown, + .void: self == argument } } diff --git a/Sources/SafeDICore/Models/Property.swift b/Sources/SafeDICore/Models/Property.swift index 3fc7c9b8..8c281043 100644 --- a/Sources/SafeDICore/Models/Property.swift +++ b/Sources/SafeDICore/Models/Property.swift @@ -96,7 +96,8 @@ public struct Property: Codable, Hashable, Comparable, Sendable { .array, .dictionary, .tuple, - .unknown: + .unknown, + .void: FunctionParameterSyntax( firstName: .identifier(label), colon: .colonToken(trailingTrivia: .space), @@ -123,7 +124,7 @@ public struct Property: Codable, Hashable, Comparable, Sendable { } else { return .constant } - case .any, .array, .attributed, .closure, .composition, .dictionary, .implicitlyUnwrappedOptional, .metatype, .nested, .optional, .some, .tuple, .unknown: + case .any, .array, .attributed, .closure, .composition, .dictionary, .implicitlyUnwrappedOptional, .metatype, .nested, .optional, .some, .tuple, .unknown, .void: return .constant } } @@ -133,7 +134,7 @@ public struct Property: Codable, Hashable, Comparable, Sendable { case let .simple(_, generics), let .nested(_, _, generics): return generics - case .any, .array, .attributed, .closure, .composition, .dictionary, .implicitlyUnwrappedOptional, .metatype, .optional, .some, .tuple, .unknown: + case .any, .array, .attributed, .closure, .composition, .dictionary, .implicitlyUnwrappedOptional, .metatype, .optional, .some, .tuple, .unknown, .void: return [] } } diff --git a/Sources/SafeDICore/Models/TypeDescription.swift b/Sources/SafeDICore/Models/TypeDescription.swift index b4c9bc68..5919595f 100644 --- a/Sources/SafeDICore/Models/TypeDescription.swift +++ b/Sources/SafeDICore/Models/TypeDescription.swift @@ -22,6 +22,8 @@ import SwiftSyntax /// An enum that describes a parsed type in a canonical form. public enum TypeDescription: Codable, Hashable, Comparable, Sendable { + /// The Void or () type. + case void(VoidSpelling) /// A root type with possible generics. e.g. Int, or Array indirect case simple(name: String, generics: [TypeDescription]) /// A nested type with possible generics. e.g. Array.Element or Swift.Array @@ -64,6 +66,8 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { /// A canonical representation of this type that can be used in source code. public var asSource: String { switch self { + case let .void(representation): + return representation.description case let .simple(name, generics): if generics.isEmpty { return name @@ -137,6 +141,32 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { lhs.asSource < rhs.asSource } + public enum VoidSpelling: String, Codable, Hashable, Sendable, CustomStringConvertible { + /// The `()` spelling. + case tuple + /// The `Void` spelling. + case identifier + + public static func == (lhs: VoidSpelling, rhs: VoidSpelling) -> Bool { + // Void is functionally equivalent no matter how it is spelled. + true + } + + public func hash(into hasher: inout Hasher) { + // Void representations have an equivalent hash because they are equivalent types. + hasher.combine(0) + } + + public var description: String { + switch self { + case .identifier: + "Void" + case .tuple: + "()" + } + } + } + public struct TupleElement: Codable, Hashable, Sendable { public let label: String? public let typeDescription: TypeDescription @@ -156,7 +186,8 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { .simple, .some, .tuple, - .unknown: + .unknown, + .void: return false case .optional: return true @@ -177,7 +208,8 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { .optional, .simple, .some, - .tuple: + .tuple, + .void: return false case .unknown: return true @@ -204,7 +236,7 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { let .optional(typeDescription), let .some(typeDescription): return typeDescription.asInstantiatedType - case .array, .attributed, .closure, .composition, .dictionary, .metatype, .nested, .tuple, .unknown: + case .array, .attributed, .closure, .composition, .dictionary, .metatype, .nested, .tuple, .unknown, .void: return self } } @@ -221,9 +253,14 @@ extension TypeSyntax { if let genericArgumentClause = typeIdentifier.genericArgumentClause { genericTypeVisitor.walk(genericArgumentClause) } - return .simple( - name: typeIdentifier.name.text, - generics: genericTypeVisitor.genericArguments) + if genericTypeVisitor.genericArguments.isEmpty && typeIdentifier.name.text == "Void" { + return .void(.identifier) + } else { + return .simple( + name: typeIdentifier.name.text, + generics: genericTypeVisitor.genericArguments + ) + } } else if let typeIdentifier = MemberTypeSyntax(self) { let genericTypeVisitor = GenericArgumentVisitor(viewMode: .sourceAccurate) @@ -233,7 +270,8 @@ extension TypeSyntax { return .nested( name: typeIdentifier.name.text, parentType: typeIdentifier.baseType.typeDescription, - generics: genericTypeVisitor.genericArguments) + generics: genericTypeVisitor.genericArguments + ) } else if let typeIdentifiers = CompositionTypeSyntax(self) { return .composition(UnorderedEquatingCollection(typeIdentifiers.elements.map { $0.type.typeDescription })) @@ -263,7 +301,8 @@ extension TypeSyntax { return .attributed( typeIdentifier.baseType.typeDescription, specifier: typeIdentifier.specifier?.text, - attributes: attributes.isEmpty ? nil : attributes) + attributes: attributes.isEmpty ? nil : attributes + ) } else if let typeIdentifier = ArrayTypeSyntax(self) { return .array(element: typeIdentifier.element.typeDescription) @@ -271,15 +310,25 @@ extension TypeSyntax { } else if let typeIdentifier = DictionaryTypeSyntax(self) { return .dictionary( key: typeIdentifier.key.typeDescription, - value: typeIdentifier.value.typeDescription) + value: typeIdentifier.value.typeDescription + ) } else if let typeIdentifier = TupleTypeSyntax(self) { - return .tuple(typeIdentifier.elements.map { + let elements = typeIdentifier.elements.map { TypeDescription.TupleElement( label: $0.secondName?.text ?? $0.firstName?.text, typeDescription: $0.type.typeDescription ) - }) + } + if elements.isEmpty { + return .void(.tuple) + } else if elements.count == 1 { + // A type wrapped in a tuple is equivalent to the underlying type. + // To avoid handling complex comparisons later, just strip the type. + return elements[0].typeDescription + } else { + return .tuple(elements) + } } else if ClassRestrictionTypeSyntax(self) != nil { // A class restriction is the same as requiring inheriting from AnyObject: @@ -291,7 +340,8 @@ extension TypeSyntax { arguments: typeIdentifier.parameters.map { $0.type.typeDescription }, isAsync: typeIdentifier.effectSpecifiers?.asyncSpecifier != nil, doesThrow: typeIdentifier.effectSpecifiers?.throwsSpecifier != nil, - returnType: typeIdentifier.returnClause.type.typeDescription) + returnType: typeIdentifier.returnClause.type.typeDescription + ) } else { // The description is a source-accurate description of this node, so it is a reasonable fallback. @@ -353,7 +403,7 @@ extension ExprSyntax { name: name, parentType: parentType, generics: genericTypeVisitor.genericArguments ) - case .any, .array, .attributed, .closure, .composition, .dictionary, .implicitlyUnwrappedOptional, .metatype, .optional, .some, .tuple, .unknown: + case .any, .array, .attributed, .closure, .composition, .dictionary, .implicitlyUnwrappedOptional, .metatype, .optional, .some, .tuple, .unknown, .void: return .unknown(text: trimmedDescription) } } else if let tupleExpr = TupleExprSyntax(self) { diff --git a/Sources/SafeDIMacros/Macros/InjectableMacro.swift b/Sources/SafeDIMacros/Macros/InjectableMacro.swift index 81ef9480..ef12e80c 100644 --- a/Sources/SafeDIMacros/Macros/InjectableMacro.swift +++ b/Sources/SafeDIMacros/Macros/InjectableMacro.swift @@ -47,7 +47,7 @@ public struct InjectableMacro: PeerMacro { switch TypeSyntax(stringLiteral: stringLiteral).typeDescription { case .simple: break - case .nested, .composition, .optional, .implicitlyUnwrappedOptional, .some, .any, .metatype, .attributed, .array, .dictionary, .tuple, .closure, .unknown: + case .nested, .composition, .optional, .implicitlyUnwrappedOptional, .some, .any, .metatype, .attributed, .array, .dictionary, .tuple, .closure, .unknown, .void: throw InjectableError.fulfilledByTypeArgumentInvalidTypeDescription } } else { diff --git a/Tests/SafeDICoreTests/TypeDescriptionTests.swift b/Tests/SafeDICoreTests/TypeDescriptionTests.swift index 5720e0ab..046fa6b0 100644 --- a/Tests/SafeDICoreTests/TypeDescriptionTests.swift +++ b/Tests/SafeDICoreTests/TypeDescriptionTests.swift @@ -27,6 +27,19 @@ import XCTest final class TypeDescriptionTests: XCTestCase { + func test_typeDescription_whenCalledOnATypeSyntaxNodeRepresentingAVoidTypeIdentifierSyntax_findsTheType() throws { + let content = """ + var void: Void = () + """ + + let visitor = TypeIdentifierSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try XCTUnwrap(visitor.typeIdentifier) + XCTAssertFalse(typeDescription.isUnknown, "Type description is not of known type!") + XCTAssertEqual(typeDescription.asSource, "Void") + } + func test_typeDescription_whenCalledOnATypeSyntaxNodeRepresentingATypeIdentifierSyntax_findsTheType() throws { let content = """ var int: Int = 1 @@ -302,6 +315,45 @@ final class TypeDescriptionTests: XCTestCase { XCTAssertEqual(typeDescription.asSource, "Dictionary>") } + func test_typeDescription_whenCalledOnATypeSyntaxNodeRepresentingAVoidTupleTypeSyntax_findsTheType() throws { + let content = """ + var voidTuple: () + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try XCTUnwrap(visitor.tupleTypeIdentifier) + XCTAssertFalse(typeDescription.isUnknown, "Type description is not of known type!") + XCTAssertEqual(typeDescription.asSource, "()") + } + + func test_typeDescription_whenCalledOnATypeSyntaxNodeRepresentingASpelledOutVoidWrappedInTupleTypeSyntax_findsTheType() throws { + let content = """ + var voidTuple: (Void) + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try XCTUnwrap(visitor.tupleTypeIdentifier) + XCTAssertFalse(typeDescription.isUnknown, "Type description is not of known type!") + XCTAssertEqual(typeDescription.asSource, "Void") + } + + func test_typeDescription_whenCalledOnATypeSyntaxNodeRepresentingAVoidWrappedInTupleTypeSyntax_findsTheType() throws { + let content = """ + var voidTuple: (()) + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try XCTUnwrap(visitor.tupleTypeIdentifier) + XCTAssertFalse(typeDescription.isUnknown, "Type description is not of known type!") + XCTAssertEqual(typeDescription.asSource, "()") + } + func test_typeDescription_whenCalledOnATypeSyntaxNodeRepresentingATupleTypeSyntax_findsTheType() throws { let content = """ var tuple: (Int, String) @@ -315,6 +367,19 @@ final class TypeDescriptionTests: XCTestCase { XCTAssertEqual(typeDescription.asSource, "(Int, String)") } + func test_typeDescription_whenCalledOnATypeSyntaxNodeRepresentingASigleElementTupleTypeSyntax_findsTheType() throws { + let content = """ + var tupleWrappedString: (String) + """ + + let visitor = TupleTypeSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try XCTUnwrap(visitor.tupleTypeIdentifier) + XCTAssertFalse(typeDescription.isUnknown, "Type description is not of known type!") + XCTAssertEqual(typeDescription.asSource, "String") + } + func test_typeDescription_whenCalledOnATypeSyntaxNodeRepresentingAClassRestrictionTypeSyntax_findsTheType() throws { let content = """ protocol SomeObject: class {} @@ -353,6 +418,18 @@ final class TypeDescriptionTests: XCTestCase { XCTAssertEqual(typeDescription.asSource, "(Int, Double) throws -> String") } + func test_typeDescription_whenCalledOnAExprSyntaxNodeRepresentingAVoidType_findsTheType() throws { + let content = """ + let type: Void.Type = Void.self + """ + let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) + visitor.walk(Parser.parse(source: content)) + + let typeDescription = try XCTUnwrap(visitor.typeDescription) + XCTAssertFalse(typeDescription.isUnknown, "Type description is not of known type!") + XCTAssertEqual(typeDescription.asSource, "Void") + } + func test_typeDescription_whenCalledOnAExprSyntaxNodeRepresentingASimpleType_findsTheType() throws { let content = """ let type: Any.Type = String.self @@ -511,7 +588,7 @@ final class TypeDescriptionTests: XCTestCase { func test_typeDescription_whenCalledOnAExprSyntaxNodeRepresentingAThrowingClosureType_findsTheType() throws { let content = """ - let test: Any.Type = (() throws -> ()).self + let test: Any.Type = (((() throws -> ()))).self """ let visitor = MemberAccessExprSyntaxVisitor(viewMode: .sourceAccurate) visitor.walk(Parser.parse(source: content)) @@ -547,6 +624,20 @@ final class TypeDescriptionTests: XCTestCase { XCTAssertEqual(typeDescription.asSource, "<[]>") } + func test_equality_isTrueWhenComparingDifferentVoidSpellings() { + XCTAssertEqual( + TypeDescription.void(.identifier), + TypeDescription.void(.tuple) + ) + } + + func test_equality_isTrueWhenComparingDifferentVoidSpellingsInHashedCollections() { + XCTAssertEqual( + Set([TypeDescription.void(.identifier)]), + Set([TypeDescription.void(.tuple)]) + ) + } + func test_equality_isTrueWhenComparingLexigraphicallyEquivalentCompositions() { XCTAssertEqual( TypeDescription.composition([ diff --git a/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift b/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift index 65b83f19..f54dd187 100644 --- a/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift +++ b/Tests/SafeDIMacrosTests/InstantiableMacroTests.swift @@ -272,6 +272,32 @@ final class InstantiableMacroTests: XCTestCase { } } + func test_declaration_doesNotGenerateRequiredInitializerIfItAlreadyExistsWithTupleWrappedClosureDependency() { + assertMacro { + """ + @Instantiable + public struct ExampleService { + public init(closure: @escaping @Sendable () -> Void) { + self.closure = closure + } + @Forwarded + let closure: (() -> Void) + } + """ + } expansion: { + """ + public struct ExampleService { + public init(closure: @escaping @Sendable () -> Void) { + self.closure = closure + } + let closure: (() -> Void) + + public typealias ForwardedArguments = () -> Void + } + """ + } + } + func test_declaration_generatesRequiredInitializerWithDependencies() { assertMacro { """