diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9980449..188d70f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,10 @@ on: branches: - "**:**" # PRs from forks have a prefix with `owner:` +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: macos_tests: name: macOS Tests (SwiftPM, Xcode ${{ matrix.xcode }}) @@ -25,9 +29,11 @@ jobs: uses: actions/cache@v3 with: path: .build - key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }}-${{ hashFiles('Package.resolved') }} + key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-${{ github.ref }}-${{ hashFiles('**/Package.resolved') }} restore-keys: | - ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }} + ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-${{ github.ref }}- + ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-main- + ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm- - name: SwiftPM tests run: swift test --enable-code-coverage @@ -75,21 +81,14 @@ jobs: - run: mint bootstrap - - name: Cache SwiftPM - uses: actions/cache@v3 - with: - path: .build - key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-${{ hashFiles('**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm- - - name: Cache DerivedData uses: actions/cache@v3 with: path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-${{ matrix.platform }}-derived_data-xcode_${{ matrix.xcode }} + key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-derived_data-${{ github.ref }} restore-keys: | - ${{ runner.os }}-${{ matrix.platform }}-derived_data- + ${{ runner.os }}-xcode_${{ matrix.xcode }}-derived_data-main + ${{ runner.os }}-xcode_${{ matrix.xcode }}-derived_data- - name: Run Tests run: | @@ -128,9 +127,11 @@ jobs: uses: actions/cache@v3 with: path: .build - key: ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }}-${{ hashFiles('Package.resolved') }} + key: ${{ runner.os }}-swift_${{ matrix.swift }}-swiftpm-deps-${{ github.ref }}-${{ hashFiles('Package.resolved') }} restore-keys: | - ${{ runner.os }}-xcode_${{ matrix.xcode }}-swiftpm-deps-${{ github.workspace }} + ${{ runner.os }}-swift_${{ matrix.swift }}-swiftpm-deps-${{ github.ref }}- + ${{ runner.os }}-swift_${{ matrix.swift }}-swiftpm-deps-main- + ${{ runner.os }}-swift_${{ matrix.swift }}-swiftpm-deps- - name: swift test run: swift test diff --git a/Package.swift b/Package.swift index 777d07a..2e0e6c7 100644 --- a/Package.swift +++ b/Package.swift @@ -31,13 +31,29 @@ let package = Package( .target( name: "HashableMacro", dependencies: [ + .targetItem( + name: "HashableMacroFoundation", + condition: .when( + platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst] + ) + ), "HashableMacroMacros", ], swiftSettings: [.enableExperimentalFeature("StrictConcurrency")] ), + .target( + name: "HashableMacroFoundation", + swiftSettings: [.enableExperimentalFeature("StrictConcurrency")] + ), .macro( name: "HashableMacroMacros", dependencies: [ + .targetItem( + name: "HashableMacroFoundation", + condition: .when( + platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst] + ) + ), .product(name: "SwiftDiagnostics", package: "swift-syntax"), .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), diff --git a/README.md b/README.md index 9985828..522aece 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,13 @@ > [!WARNING] > This package requires Swift 5.9.2, which ships with Xcode 15.1. It is possible to add this package in Xcode 15.0 and 15.0.1 but the `@Hashable` macro will not be available. -`HashableMacro` is a Swift macro for adding `Hashable` conformance. It is particularly useful when synthesised conformance is not possible, such as with classes or a struct with 1 or more non-hashable properties. +`@Hashable` is a Swift macro for adding `Hashable` conformance. It is particularly useful when synthesised conformance is not possible, such as with classes or a struct with 1 or more non-hashable properties. The `@Hashable` macro is applied to the type that will conform to `Hashable` and the `Hashed` macro is applied to each of the properties that should contribute to the `Hashable` conformance. ```swift +import HashableMacro + /// A struct that uses the ``stringProperty`` and ``intProperty`` for `Hashable` conformance. @Hashable struct MyStruct { @@ -52,7 +54,7 @@ struct MyStruct { ## `@Hashable` Only -If the `@Hashable` macro is added but no properties are decorated with `@Hashed` or `@NotHashed` then all properties will be used. +If the `@Hashable` macro is added but no properties are decorated with `@Hashed` or `@NotHashed` then all stored properties will be used. ```swift /// A struct that uses the ``stringProperty`` and ``intProperty`` for `Hashable` conformance. @@ -66,7 +68,7 @@ struct MyStruct { // Implicitly excluded from `Hashable` conformance var computedProperty: Bool { - intProperty > 0 + intProperty > 0 } } ``` @@ -75,20 +77,56 @@ One (fairly minor) advantage of this over adding `Hashable` conformance without ## `NSObject` Support -When a type inherits from `NSObject` it should override `hash` and `isEqual(_:)`, not `hash(into:)` and `==`. `HashableMacro` detects when it is attached to a type conforming to `NSObjectProtocol` and will provide the `hash` property and `isEqual(_:)` function instead. +When a type implements `NSObjectProtocol` (e.g. it inherits from `NSObject`) it should override `hash` and `isEqual(_:)`, not `hash(into:)` and `==`. `@Hashable` detects when it is attached to a type conforming to `NSObjectProtocol` and will provide the `hash` property and `isEqual(_:)` function instead. + +`@Hashable` will also provide an `isEqual(to:)` function that takes a parameter that matches `Self`, which will also have an appropriately named Objective-C function. + +```swift +import HashableMacro + +@Hashable +final class Person: NSObject { + @Hashed + var name: String = "" +} + +extension Person { + override var hash: Int { + var hasher = Hasher() + hasher.combine(self.name) + return hasher.finalize() + } +} -By default `HashableMacro` will incorporate `super.isEqual(_:)` and `super.hash`, unless the type is a direct subclass of `NSObject`. This behaviour can be changed with the `nsObjectSubclassBehaviour` parameter. +extension Person { + override func isEqual(_ object: Any?) -> Bool { + guard let object = object as? Person else { + return false + } + guard type(of: self) == type(of: object) else { + return false + } + return self.isEqual(to: object) + } + @objc(isEqualToPerson:) + func isEqual(to object: Person) -> Bool { + return self.name == object.name + } +} +``` ## `final` `hash(into:)` Function -When the `HashableMacro` macro is added to a class the generated `hash(into:)` function is marked `final`. This is because subclasses should not overload `==`. There are many reasons why this can be a bad idea, but specifically in Swift this does not work because: +When the `@Hashable` macro is added to a class the generated `hash(into:)` function is marked `final`. This is because subclasses should not overload `==`. There are many reasons why this can be a bad idea, but specifically in Swift this does not work because: - `!=` is not part of the `Equatable` protocol, but rather an extension on `Equatable`, causing it to always use the `==` implementation from the class that adds `Equatable` conformance - It is possible to overload `!=` but this is still not a good idea because... - Anything that uses generics to compare the values, for example `XCTAssertEqual`, will use the `==` implementation from the class that adds `Equatable` conformance - - It is possible to work around this by using a separate function, in a similar way to `NSObjectProtocol`, which is then called from `==`, but this requires extra decisions to be made that shouldn't be made by this library, e.g. what to do when a subclass is compared to its superclass. + - It is possible to work around this by using a separate function, in a similar way to `NSObject`, which is then called from `==` + +If this is an issue for your usage you can pass `finalHashInto: false` to the macro, but it will not attempt to call `super` or use properties from the superclass. -If this is an issue for your usage you can pass `finalHashInto: false` to the macro, but it will not attempt to call `super` or use the properties annotated with `@Hashed` from the superclass. +This is not something the macro aims to solve. ## License diff --git a/Sources/HashableMacro/IsEqualToTypeFunctionNameGeneration.swift b/Sources/HashableMacro/IsEqualToTypeFunctionNameGeneration.swift new file mode 100644 index 0000000..53a3ada --- /dev/null +++ b/Sources/HashableMacro/IsEqualToTypeFunctionNameGeneration.swift @@ -0,0 +1,9 @@ +#if canImport(HashableMacroFoundation) +#if canImport(ObjectiveC) +import HashableMacroFoundation + +public typealias IsEqualToTypeFunctionNameGeneration = HashableMacroFoundation.IsEqualToTypeFunctionNameGeneration +#else +#warning("ObjectiveC should be importable when HashableMacroFoundation can be imported") +#endif +#endif diff --git a/Sources/HashableMacro/Macros.swift b/Sources/HashableMacro/Macros.swift index ed42c22..3e267ba 100644 --- a/Sources/HashableMacro/Macros.swift +++ b/Sources/HashableMacro/Macros.swift @@ -1,50 +1,65 @@ #if canImport(ObjectiveC) import ObjectiveC +import HashableMacroFoundation /// A macro that adds `Hashable` conformance to the type it is attached to. The -/// `==` function and `hash(into:)` functions will use the same properties. To -/// include a property decorate it with the ``Hashed()`` macro. +/// hash generation and equality checks will use the same properties. To include +/// a property decorate it with the ``Hashed()`` macro. Alternatively the +/// ``NotHashed()`` can be used with struct properties to opt-out a subset of +/// properties, rather then opt-in. /// -/// If this is attached to a type conforming to `NSObjectProtocol` this will -/// instead override the `hash` property and `isEqual(_:)` function. +/// When attached to a struct with only hashable properties the ``Hashed()`` and +/// ``NotHashed()`` macros can be omitted and all properties will be used. +/// +/// When attached to a Swift type this macro will provide the `hash(into:)` +/// function and the `==(lhs:rhs:)` function. When attached to a type conforming +/// to `NSObjectProtocol` this will produce a `hash` property, an `isEqual(_:)` +/// function, and as `isEqualToType(_:)`-style function +/// +/// For types conforming to `NSObjectProtocol` another object will only compare +/// equal if it of the same class (e.g. subclasses and superclasses will never +/// compare equal) and all annotated properties are equal. /// /// - parameter finalHashInto: When `true`, and the macro is attached to a -/// class that doesn't implement `NSObjectProtocol`, the `hash(into:)` -/// function will be marked `final`. This helps avoid a pitfall when -/// subclassing an `Equatable` class: the `==` function cannot be overridden -/// in a subclass and `==` will always use the superclass. -@attached(extension, conformances: Hashable, Equatable, NSObjectProtocol, names: named(hash), named(==), named(isEqual(_:)), named(hash)) -@available(swift 5.9.2) +/// class, the `hash(into:)` function will be marked `final`. This helps avoid +/// a pitfall when subclassing an `Equatable` class: the `==` function cannot +/// be overridden in a subclass and `==` will always use the superclass. +/// - parameter isEqualToTypeFunctionName: The name to use when using the +/// `isEqual(to:)` function from Objective-C. Defaults to using the name of the +/// class the macro is attached to. This only applies to types that conform to +/// `NSObjectProtocol`. +#if compiler(>=5.9.2) +@attached( + extension, + conformances: Hashable, Equatable, NSObjectProtocol, + names: named(hash(into:)), named(==), named(hash), named(isEqual(_:)), named(isEqual(to:)), arbitrary +) +#else +@attached(extension) +#endif public macro Hashable( finalHashInto: Bool = true, - nsObjectSubclassBehaviour: NSObjectSubclassBehaviour = .callSuperUnlessDirectSubclass + isEqualToTypeFunctionName: IsEqualToTypeFunctionNameGeneration = .automatic ) = #externalMacro(module: "HashableMacroMacros", type: "HashableMacro") - -public enum NSObjectSubclassBehaviour: Sendable { - /// Never call `super.isEqual(to:)` and do not incorporate `super.hash`. - case neverCallSuper - - /// Call `super.isEqual(to:)` and incorporate `super.hash` only when the - /// type is not a direct subclass of `NSObject`. - case callSuperUnlessDirectSubclass - - /// Always call `super.isEqual(to:)` and incorporate `super.hash`. - case alwaysCallSuper -} #else /// A macro that adds `Hashable` conformance to the type it is attached to. The -/// `==` function and `hash(into:)` functions will use the same properties. To -/// include a property decorate it with the ``Hashed()`` macro. +/// hash generation and equality checks will use the same properties. To include +/// a property decorate it with the ``Hashed()`` macro. Alternatively the +/// ``NotHashed()`` can be used with struct properties to opt-out a subset of +/// properties, rather then opt-in. /// -/// If this is attached to a type conforming to `NSObjectProtocol` this will -/// instead override the `hash` property and `isEqual(_:)` function. +/// When attached to a struct with only hashable properties the ``Hashed()`` and +/// ``NotHashed()`` macros can be omitted and all properties will be used. /// /// - parameter finalHashInto: When `true`, and the macro is attached to a /// class, the `hash(into:)` function will be marked `final`. This helps avoid /// a pitfall when subclassing an `Equatable` class: the `==` function cannot /// be overridden in a subclass and `==` will always use the superclass. +#if compiler(>=5.9.2) @attached(extension, conformances: Hashable, Equatable, names: named(hash), named(==)) -@available(swift 5.9.2) +#else +@attached(extension) +#endif public macro Hashable( finalHashInto: Bool = true ) = #externalMacro(module: "HashableMacroMacros", type: "HashableMacro") diff --git a/Sources/HashableMacroFoundation/IsEqualToTypeFunctionNameGeneration.swift b/Sources/HashableMacroFoundation/IsEqualToTypeFunctionNameGeneration.swift new file mode 100644 index 0000000..8acf2bf --- /dev/null +++ b/Sources/HashableMacroFoundation/IsEqualToTypeFunctionNameGeneration.swift @@ -0,0 +1,15 @@ +#if canImport(ObjectiveC) +/// How to generate the name of the Objective-C function used to compare 2 +/// instances of the same type. +public enum IsEqualToTypeFunctionNameGeneration: Sendable { + /// Use an automatically generated name for the Objective-C function, e.g. + /// for a class named `Person` this would use `isEqualToPerson:`. + case automatic + + /// Use the provided name for the Objective-C function. + /// + /// - parameter objectiveCName: The name of the function when used from + /// Objective-C. This should include a trailing colon. + case custom(_ objectiveCName: String) +} +#endif diff --git a/Sources/HashableMacroMacros/CustomHashablePlugin.swift b/Sources/HashableMacroMacros/CustomHashablePlugin.swift index e044d1d..fc140b5 100644 --- a/Sources/HashableMacroMacros/CustomHashablePlugin.swift +++ b/Sources/HashableMacroMacros/CustomHashablePlugin.swift @@ -5,16 +5,9 @@ import SwiftSyntaxMacros @main struct HashableMacroPlugin: CompilerPlugin { - #if compiler(>=5.9.2) let providingMacros: [Macro.Type] = [ HashableMacro.self, HashedMacro.self, NotHashedMacro.self, ] - #else - let providingMacros: [Macro.Type] = [ - HashedMacro.self, - NotHashedMacro.self, - ] - #endif } diff --git a/Sources/HashableMacroMacros/Diagnostics/HashableMacroDiagnosticMessage.swift b/Sources/HashableMacroMacros/Diagnostics/HashableMacroDiagnosticMessage.swift new file mode 100644 index 0000000..3760103 --- /dev/null +++ b/Sources/HashableMacroMacros/Diagnostics/HashableMacroDiagnosticMessage.swift @@ -0,0 +1,17 @@ +#if canImport(SwiftSyntax510) +import SwiftDiagnostics +#else +@preconcurrency import SwiftDiagnostics +#endif + +struct HashableMacroDiagnosticMessage: DiagnosticMessage, Error { + let message: String + let diagnosticID: MessageID + let severity: DiagnosticSeverity + + init(id: String, message: String, severity: DiagnosticSeverity) { + self.message = message + diagnosticID = MessageID.makeHashableMacroMessageID(id: id) + self.severity = severity + } +} diff --git a/Sources/HashableMacroMacros/Diagnostics/HashableMacroFixItMessage.swift b/Sources/HashableMacroMacros/Diagnostics/HashableMacroFixItMessage.swift new file mode 100644 index 0000000..5ae87eb --- /dev/null +++ b/Sources/HashableMacroMacros/Diagnostics/HashableMacroFixItMessage.swift @@ -0,0 +1,12 @@ +import SwiftDiagnostics + +struct HashableMacroFixItMessage: FixItMessage { + let fixItID: MessageID + let message: String + + init(id: String, message: String) { + fixItID = MessageID.makeHashableMacroMessageID(id: id) + self.message = message + } +} + diff --git a/Sources/HashableMacroMacros/Diagnostics/MessageID+HashableMacro.swift b/Sources/HashableMacroMacros/Diagnostics/MessageID+HashableMacro.swift new file mode 100644 index 0000000..6a802e9 --- /dev/null +++ b/Sources/HashableMacroMacros/Diagnostics/MessageID+HashableMacro.swift @@ -0,0 +1,7 @@ +import SwiftDiagnostics + +extension MessageID { + static func makeHashableMacroMessageID(id: String) -> MessageID { + MessageID(domain: "uk.josephduffy.HashableMacro", id: id) + } +} diff --git a/Sources/HashableMacroMacros/HashableMacro.swift b/Sources/HashableMacroMacros/HashableMacro.swift deleted file mode 100644 index b1a9f41..0000000 --- a/Sources/HashableMacroMacros/HashableMacro.swift +++ /dev/null @@ -1,880 +0,0 @@ -import Foundation -#if canImport(SwiftSyntax510) -import SwiftDiagnostics -#else -@preconcurrency import SwiftDiagnostics -#endif -import SwiftSyntax -import SwiftSyntaxMacros - -@available(swift 5.9.2) -public struct HashableMacro: ExtensionMacro { - public static func expansion( - of node: AttributeSyntax, - attachedTo declaration: some DeclGroupSyntax, - providingExtensionsOf type: some TypeSyntaxProtocol, - conformingTo protocols: [TypeSyntax], - in context: some MacroExpansionContext - ) throws -> [ExtensionDeclSyntax] { - // The macro declares that it can add `NSObjectProtocol`, but this is - // used to check whether the compiler asks for it to be added. If the - // macro is asked to add `NSObjectProtocol` conformance then we know - // this is not an `NSObject` subclass. - #if canImport(ObjectiveC) - var isNSObjectSubclass = true - #endif - - var protocolExtensions: [ExtensionDeclSyntax] = [] - - for protocolType in protocols { - switch protocolType.trimmedDescription { - case "Hashable", "Equatable": - let protocolExtension = ExtensionDeclSyntax( - extendedType: type, - inheritanceClause: InheritanceClauseSyntax( - inheritedTypes: InheritedTypeListSyntax(itemsBuilder: { - InheritedTypeSyntax( - type: protocolType - ) - }) - ), - memberBlock: MemberBlockSyntax(members: "") - ) - protocolExtensions.append(protocolExtension) - #if canImport(ObjectiveC) - case "NSObjectProtocol": - isNSObjectSubclass = false - #endif - default: - throw HashableMacroDiagnosticMessage( - id: "unknown-protocol", - message: "Unknown protocol: '\(protocolType.trimmedDescription)'", - severity: .error - ) - } - } - - let properties = declaration.memberBlock.members.compactMap({ $0.decl.as(VariableDeclSyntax.self) }) - var explicitlyHashedProperties: [TokenSyntax] = [] - var undecoratedProperties: [TokenSyntax] = [] - var notHashedAttributes: [AttributeSyntax] = [] - - for property in properties { - let bindings = property.bindings.compactMap({ binding in - binding - .pattern - .as(IdentifierPatternSyntax.self)? - .identifier - }) - lazy var isCalculated = property.bindings.contains { binding in - guard let accessorBlock = binding.accessorBlock else { return false } - switch accessorBlock.accessors { - case .getter: - return true - case .accessors(let accessors): - for accessor in accessors { - switch accessor.accessorSpecifier.tokenKind { - case .keyword(.get): - return true - default: - break - } - } - } - return false - } - - func attribute(named macroName: String) -> AttributeSyntax? { - for attribute in property.attributes { - guard let attribute = attribute.as(AttributeSyntax.self) else { continue } - let identifier = attribute - .attributeName - .as(IdentifierTypeSyntax.self) - if identifier?.name.tokenKind == .identifier(macroName) { - return attribute - } - } - - return nil - } - - if attribute(named: "Hashed") != nil { - explicitlyHashedProperties.append(contentsOf: bindings) - } else if let notHashedAttribute = attribute(named: "NotHashed") { - notHashedAttributes.append(notHashedAttribute) - } else if !isCalculated { - undecoratedProperties.append(contentsOf: bindings) - } - } - - if !explicitlyHashedProperties.isEmpty { - for notHashedAttribute in notHashedAttributes { - let fixIt = FixIt( - message: HashableMacroFixItMessage( - id: "redundant-not-hashed", - message: "Remove @NotHashed" - ), - changes: [ - FixIt.Change.replace( - oldNode: Syntax(notHashedAttribute), - newNode: Syntax("" as DeclSyntax) - ) - ] - ) - let diagnostic = Diagnostic( - node: Syntax(notHashedAttribute), - message: HashableMacroDiagnosticMessage( - id: "redundant-not-hashed", - message: "The @NotHashed macro is redundant when 1 or more properties are decorated @Hashed. It will be ignored", - severity: .warning - ), - fixIt: fixIt - ) - context.diagnose(diagnostic) - } - } - - let propertiesToHash = !explicitlyHashedProperties.isEmpty ? explicitlyHashedProperties : undecoratedProperties - - #if canImport(ObjectiveC) - #if DEBUG - // The testing library does not process the required protocols and - // passes and empty array for `protocols`. This means that the macro - // assumes that the type conforms to `NSObjectProtocol`. This argument - // cannot be passed in code but it can be passed when the input code is - // written as a string. - if let arguments = node.arguments?.as(LabeledExprListSyntax.self) { - for argument in arguments { - switch argument.label?.trimmed.text { - case "_disableNSObjectSubclassSupport": - guard let expression = argument.expression.as(BooleanLiteralExprSyntax.self) else { continue } - switch expression.literal.tokenKind { - case .keyword(.true): - isNSObjectSubclass = false - default: - break - } - default: - break - } - } - } - #endif - if isNSObjectSubclass { - guard let namedDeclaration = declaration as? ClassDeclSyntax else { - throw HashableMacroDiagnosticMessage( - id: "nsobject-subclass-not-class", - message: "This type conforms to 'NSObjectProtocol' but is not a class", - severity: .error - ) - } - - var nsObjectSubclassBehaviour: NSObjectSubclassBehaviour = .callSuperUnlessDirectSubclass - - if let arguments = node.arguments?.as(LabeledExprListSyntax.self) { - for argument in arguments { - switch argument.label?.trimmedDescription { - case "nsObjectSubclassBehaviour": - guard let expression = argument.expression.as(MemberAccessExprSyntax.self) else { - throw HashableMacroDiagnosticMessage( - id: "unknown-nsObjectSubclassBehaviour-type", - message: "'nsObjectSubclassBehaviour' parameter was not of the expected type", - severity: .error - ) - } - switch expression.declName.baseName.tokenKind { - case .identifier("neverCallSuper"): - nsObjectSubclassBehaviour = .neverCallSuper - case .identifier("callSuperUnlessDirectSubclass"): - nsObjectSubclassBehaviour = .callSuperUnlessDirectSubclass - case .identifier("alwaysCallSuper"): - nsObjectSubclassBehaviour = .alwaysCallSuper - default: - throw HashableMacroDiagnosticMessage( - id: "unknown-nsObjectSubclassBehaviour-name", - message: "'\(expression.declName.baseName)' is not a known value for `NSObjectSubclassBehaviour`; \(expression.declName.baseName.debugDescription))", - severity: .error - ) - } - default: - break - } - } - } - - let doIncorporateSuper: Bool - - switch nsObjectSubclassBehaviour { - case .neverCallSuper: - doIncorporateSuper = false - case .callSuperUnlessDirectSubclass: - doIncorporateSuper = namedDeclaration.inheritanceClause?.inheritedTypes.first?.type.trimmedDescription != "NSObject" - case .alwaysCallSuper: - doIncorporateSuper = true - } - - let hashPropertyExtension = ExtensionDeclSyntax( - extendedType: type, - memberBlock: MemberBlockSyntax( - members: MemberBlockItemListSyntax(itemsBuilder: { - expansionForHashProperty( - of: node, - providingMembersOf: declaration, - in: context, - propertiesToHash: propertiesToHash, - doIncorporateSuper: doIncorporateSuper - ) - }) - ) - ) - let isEqualImplementationExtension = ExtensionDeclSyntax( - extendedType: type, - memberBlock: MemberBlockSyntax( - members: MemberBlockItemListSyntax(itemsBuilder: { - expansionForIsEqual( - of: node, - providingMembersOf: declaration, - in: context, - propertiesToHash: propertiesToHash, - doIncorporateSuper: doIncorporateSuper - ) - }) - ) - ) - protocolExtensions.append(hashPropertyExtension) - protocolExtensions.append(isEqualImplementationExtension) - } else { - let hashableImplementationExtension = ExtensionDeclSyntax( - extendedType: type, - memberBlock: MemberBlockSyntax( - members: MemberBlockItemListSyntax(itemsBuilder: { - expansionForHashable( - of: node, - providingMembersOf: declaration, - in: context, - propertiesToHash: propertiesToHash - ) - }) - ) - ) - let equatableImplementationExtension = ExtensionDeclSyntax( - extendedType: type, - memberBlock: MemberBlockSyntax( - members: try MemberBlockItemListSyntax(itemsBuilder: { - try expansionForEquals( - of: node, - providingMembersOf: declaration, - in: context, - propertiesToHash: propertiesToHash - ) - }) - ) - ) - protocolExtensions.append(hashableImplementationExtension) - protocolExtensions.append(equatableImplementationExtension) - } - #else - let hashableImplementationExtension = ExtensionDeclSyntax( - extendedType: type, - memberBlock: MemberBlockSyntax( - members: MemberBlockItemListSyntax(itemsBuilder: { - expansionForHashable( - of: node, - providingMembersOf: declaration, - in: context, - propertiesToHash: propertiesToHash - ) - }) - ) - ) - let equatableImplementationExtension = ExtensionDeclSyntax( - extendedType: type, - memberBlock: MemberBlockSyntax( - members: try MemberBlockItemListSyntax(itemsBuilder: { - try expansionForEquals( - of: node, - providingMembersOf: declaration, - in: context, - propertiesToHash: propertiesToHash - ) - }) - ) - ) - protocolExtensions.append(hashableImplementationExtension) - protocolExtensions.append(equatableImplementationExtension) - #endif - - return protocolExtensions - } - - private static func expansionForHashable( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext, - propertiesToHash: [TokenSyntax] - ) -> DeclSyntax { - var finalHashInto = true - - if let arguments = node.arguments?.as(LabeledExprListSyntax.self) { - for argument in arguments { - switch argument.label?.trimmed.text { - case "finalHashInto": - guard let expression = argument.expression.as(BooleanLiteralExprSyntax.self) else { continue } - switch expression.literal.tokenKind { - case .keyword(.true): - finalHashInto = true - case .keyword(.false): - finalHashInto = false - default: - break - } - default: - break - } - } - } - - let baseModifiers = declaration.modifiers.filter({ modifier in - switch (modifier.name.tokenKind) { - case .keyword(.public): - return true - case .keyword(.internal): - return true - case .keyword(.fileprivate): - return true - case .keyword(.private): - // The added functions should never be private - return false - default: - return false - } - }) - - var hashFunctionModifiers = baseModifiers - if finalHashInto, declaration.is(ClassDeclSyntax.self) { - hashFunctionModifiers.append( - DeclModifierSyntax(name: .keyword(.final)) - ) - } - - let hashFunctionSignature = FunctionSignatureSyntax( - parameterClause: FunctionParameterClauseSyntax( - parameters: [ - FunctionParameterSyntax( - firstName: .identifier("into"), - secondName: .identifier("hasher"), - type: AttributedTypeSyntax( - specifier: .keyword(.inout), - baseType: TypeSyntax(stringLiteral: "Hasher") - ) - ), - ] - ) - ) - - let hashFunctionBody = CodeBlockSyntax( - statements: CodeBlockItemListSyntax(itemsBuilder: { - for propertyToken in propertiesToHash { - FunctionCallExprSyntax( - callee: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax(baseName: "hasher"), - name: .identifier("combine") - ), - argumentList: { - LabeledExprSyntax( - expression: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax(baseName: .keyword(.`self`)), - name: propertyToken - ) - ) - } - ) - } - }) - ) - - let hashFunction = FunctionDeclSyntax( - modifiers: hashFunctionModifiers, - name: .identifier("hash"), - signature: hashFunctionSignature, - body: hashFunctionBody - ) - - return DeclSyntax(hashFunction) - } - - private static func expansionForEquals( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext, - propertiesToHash: [TokenSyntax] - ) throws -> DeclSyntax { - guard let namedDeclaration = declaration as? NamedDeclSyntax else { - throw HashableMacroDiagnosticMessage( - id: "not-named-declaration", - message: "'@Hashable' can only be applied to named declarations", - severity: .error - ) - } - - let baseModifiers = declaration.modifiers.filter({ modifier in - switch (modifier.name.tokenKind) { - case .keyword(.public): - return true - case .keyword(.internal): - return true - case .keyword(.fileprivate): - return true - case .keyword(.private): - // The added functions should never be private - return false - default: - return false - } - }) - - let equalsFunctionSignature = FunctionSignatureSyntax( - parameterClause: FunctionParameterClauseSyntax( - parameters: [ - FunctionParameterSyntax( - firstName: .identifier("lhs"), - type: TypeSyntax(stringLiteral: namedDeclaration.name.text), - trailingComma: .commaToken() - ), - FunctionParameterSyntax( - firstName: .identifier("rhs"), - type: TypeSyntax(stringLiteral: namedDeclaration.name.text) - ), - ] - ), - returnClause: ReturnClauseSyntax( - type: IdentifierTypeSyntax(name: .identifier("Bool")) - ) - ) - - var comparisons: InfixOperatorExprSyntax? - - for propertyToken in propertiesToHash { - let comparison = InfixOperatorExprSyntax( - leftOperand: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax( - baseName: .identifier("lhs") - ), - declName: DeclReferenceExprSyntax( - baseName: propertyToken - ) - ), - operator: BinaryOperatorExprSyntax( - operator: .binaryOperator("==") - ), - rightOperand: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax( - baseName: .identifier("rhs") - ), - declName: DeclReferenceExprSyntax( - baseName: propertyToken - ) - ) - ) - - if let existingComparisons = comparisons { - comparisons = InfixOperatorExprSyntax( - leftOperand: existingComparisons, - operator: BinaryOperatorExprSyntax( - leadingTrivia: .newline.appending(Trivia.spaces(8)), - operator: .binaryOperator("&&") - ), - rightOperand: comparison - ) - } else { - comparisons = comparison - } - } - - let equalsBody = CodeBlockSyntax( - statements: CodeBlockItemListSyntax(itemsBuilder: { - if let comparisons { - ReturnStmtSyntax( - leadingTrivia: .spaces(4), - expression: comparisons - ) - } else { - ReturnStmtSyntax( - leadingTrivia: .spaces(4), - expression: BooleanLiteralExprSyntax(booleanLiteral: true) - ) - } - }) - ) - - var equalsFunctionModifiers = baseModifiers - equalsFunctionModifiers.append( - DeclModifierSyntax(name: .keyword(.static)) - ) - - let equalsFunction = FunctionDeclSyntax( - modifiers: equalsFunctionModifiers, - name: .identifier("=="), - signature: equalsFunctionSignature, - body: equalsBody - ) - - return DeclSyntax(equalsFunction) - } - - private static func expansionForIsEqual( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext, - propertiesToHash: [TokenSyntax], - doIncorporateSuper: Bool - ) -> DeclSyntax { - let baseModifiers = declaration.modifiers.filter({ modifier in - switch (modifier.name.tokenKind) { - case .keyword(.public): - return true - case .keyword(.internal): - return true - case .keyword(.fileprivate): - return true - case .keyword(.private): - // The added functions should never be private - return false - default: - return false - } - }) - - let isEqualFunctionSignature = FunctionSignatureSyntax( - parameterClause: FunctionParameterClauseSyntax( - parameters: [ - FunctionParameterSyntax( - firstName: .identifier("_"), - secondName: .identifier("object"), - type: OptionalTypeSyntax(wrappedType: "Any" as TypeSyntax) - ) - ] - ), - returnClause: ReturnClauseSyntax( - type: IdentifierTypeSyntax(name: .identifier("Bool")) - ) - ) - - var comparisons: InfixOperatorExprSyntax? - - for propertyToken in propertiesToHash { - let comparison = InfixOperatorExprSyntax( - leftOperand: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax( - baseName: .keyword(.`self`) - ), - declName: DeclReferenceExprSyntax( - baseName: propertyToken - ) - ), - operator: BinaryOperatorExprSyntax( - operator: .binaryOperator("==") - ), - rightOperand: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax( - baseName: .identifier("object") - ), - declName: DeclReferenceExprSyntax( - baseName: propertyToken - ) - ) - ) - - if let existingComparisons = comparisons { - comparisons = InfixOperatorExprSyntax( - leftOperand: existingComparisons, - operator: BinaryOperatorExprSyntax( - leadingTrivia: .newline.appending(Trivia.spaces(4)), - operator: .binaryOperator("&&") - ), - rightOperand: comparison - ) - } else { - comparisons = comparison - } - } - - let isEqualBody = CodeBlockSyntax( - statements: CodeBlockItemListSyntax(itemsBuilder: { - GuardStmtSyntax( - conditions: ConditionElementListSyntax { - OptionalBindingConditionSyntax( - bindingSpecifier: .keyword(.let), - pattern: IdentifierPatternSyntax( - identifier: .identifier("object") - ) - ) - }, - bodyBuilder: { - ReturnStmtSyntax( - leadingTrivia: .spaces(4), - expression: BooleanLiteralExprSyntax(booleanLiteral: false) - ) - } - ) - GuardStmtSyntax( - conditions: ConditionElementListSyntax { - InfixOperatorExprSyntax( - leftOperand: FunctionCallExprSyntax( - calledExpression: DeclReferenceExprSyntax( - baseName: .identifier("type") - ), - leftParen: .leftParenToken(), - arguments: [ - LabeledExprSyntax( - label: "of", - expression: DeclReferenceExprSyntax( - baseName: .keyword(.`self`) - ) - ) - ], - rightParen: .rightParenToken() - ), - operator: BinaryOperatorExprSyntax( - operator: .binaryOperator("==") - ), - rightOperand: FunctionCallExprSyntax( - calledExpression: DeclReferenceExprSyntax( - baseName: .identifier("type") - ), - leftParen: .leftParenToken(), - arguments: [ - LabeledExprSyntax( - label: "of", - expression: DeclReferenceExprSyntax( - baseName: .identifier("object") - ) - ) - ], - rightParen: .rightParenToken() - ) - ) - }, - bodyBuilder: { - ReturnStmtSyntax( - leadingTrivia: .spaces(4), - expression: BooleanLiteralExprSyntax(booleanLiteral: false) - ) - } - ) - - if doIncorporateSuper { - GuardStmtSyntax( - conditions: ConditionElementListSyntax { - FunctionCallExprSyntax( - calledExpression: MemberAccessExprSyntax( - base: SuperExprSyntax(), - name: .identifier("isEqual") - ), - leftParen: .leftParenToken(), - arguments: [ - LabeledExprSyntax( - expression: DeclReferenceExprSyntax( - baseName: .identifier("object") - ) - ) - ], - rightParen: .rightParenToken() - ) - }, - bodyBuilder: { - ReturnStmtSyntax( - leadingTrivia: .spaces(4), - expression: BooleanLiteralExprSyntax(booleanLiteral: false) - ) - } - ) - } - if let comparisons { - GuardStmtSyntax( - conditions: ConditionElementListSyntax { - OptionalBindingConditionSyntax( - bindingSpecifier: .keyword(.let), - pattern: IdentifierPatternSyntax( - identifier: .identifier("object") - ), - initializer: InitializerClauseSyntax( - value: AsExprSyntax( - expression: DeclReferenceExprSyntax(baseName: "object"), - questionOrExclamationMark: .postfixQuestionMarkToken(), - type: IdentifierTypeSyntax( - name: .keyword(.`Self`) - ) - ) - ) - ) - }, - bodyBuilder: { - ReturnStmtSyntax( - leadingTrivia: .spaces(4), - expression: BooleanLiteralExprSyntax(booleanLiteral: false) - ) - } - ) - ReturnStmtSyntax( - expression: comparisons - ) - } else { - ReturnStmtSyntax( - expression: BooleanLiteralExprSyntax(booleanLiteral: true) - ) - } - }) - ) - - var equalsFunctionModifiers = baseModifiers - equalsFunctionModifiers.append( - DeclModifierSyntax(name: .keyword(.override)) - ) - - let equalsFunction = FunctionDeclSyntax( - modifiers: equalsFunctionModifiers, - name: .identifier("isEqual"), - signature: isEqualFunctionSignature, - body: isEqualBody - ) - - return DeclSyntax(equalsFunction) - } - - private static func expansionForHashProperty( - of node: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext, - propertiesToHash: [TokenSyntax], - doIncorporateSuper: Bool - ) -> DeclSyntax { - let baseModifiers = declaration.modifiers.filter({ modifier in - switch (modifier.name.tokenKind) { - case .keyword(.public): - return true - case .keyword(.internal): - return true - case .keyword(.fileprivate): - return true - case .keyword(.private): - // The added functions should never be private - return false - default: - return false - } - }) - - var hashPropertyModifiers = baseModifiers - hashPropertyModifiers.append( - DeclModifierSyntax(name: .keyword(.override)) - ) - - let hashPropertyDeclaration = VariableDeclSyntax( - modifiers: hashPropertyModifiers, - bindingSpecifier: .keyword(.var), - bindings: PatternBindingListSyntax([ - PatternBindingSyntax( - pattern: IdentifierPatternSyntax(identifier: .identifier("hash")), - typeAnnotation: TypeAnnotationSyntax( - type: IdentifierTypeSyntax(name: .identifier("Int")) - ), - accessorBlock: AccessorBlockSyntax( - accessors: .getter(CodeBlockItemListSyntax(itemsBuilder: { - let havePropertiesToHash = doIncorporateSuper || !propertiesToHash.isEmpty - - VariableDeclSyntax( - bindingSpecifier: .keyword(havePropertiesToHash ? .var : .let), - bindings: PatternBindingListSyntax([ - PatternBindingSyntax( - pattern: IdentifierPatternSyntax(identifier: .identifier("hasher")), - initializer: InitializerClauseSyntax( - value: FunctionCallExprSyntax( - calledExpression: DeclReferenceExprSyntax( - baseName: TokenSyntax.identifier("Hasher") - ), - leftParen: .leftParenToken(), - arguments: [], - rightParen: .rightParenToken() - ) - ) - ) - ]) - ) - - if doIncorporateSuper { - FunctionCallExprSyntax( - callee: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax(baseName: "hasher"), - name: .identifier("combine") - ), - argumentList: { - LabeledExprSyntax( - expression: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax(baseName: .keyword(.super)), - name: .identifier("hash") - ) - ) - } - ) - } - - for propertyToken in propertiesToHash { - FunctionCallExprSyntax( - callee: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax(baseName: "hasher"), - name: .identifier("combine") - ), - argumentList: { - LabeledExprSyntax( - expression: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax(baseName: .keyword(.`self`)), - name: propertyToken - ) - ) - } - ) - } - - ReturnStmtSyntax( - expression: FunctionCallExprSyntax( - calledExpression: MemberAccessExprSyntax( - base: DeclReferenceExprSyntax(baseName: .identifier("hasher")), - name: .identifier("finalize") - ), - leftParen: .leftParenToken(), - arguments: [], - rightParen: .rightParenToken() - ) - ) - })) - ) - ) - ]) - ) - - return DeclSyntax(hashPropertyDeclaration) - } -} - -private struct HashableMacroDiagnosticMessage: DiagnosticMessage, Error { - let message: String - let diagnosticID: MessageID - let severity: DiagnosticSeverity - - init(id: String, message: String, severity: DiagnosticSeverity) { - self.message = message - diagnosticID = MessageID(domain: "uk.josephduffy.HashableMacro", id: id) - self.severity = severity - } -} - -private struct HashableMacroFixItMessage: FixItMessage { - let fixItID: MessageID - let message: String - - init(id: String, message: String) { - fixItID = MessageID(domain: "uk.josephduffy.HashableMacro", id: id) - self.message = message - } -} diff --git a/Sources/HashableMacroMacros/Macros/HashableMacro+Hashable.swift b/Sources/HashableMacroMacros/Macros/HashableMacro+Hashable.swift new file mode 100644 index 0000000..4fd4a9a --- /dev/null +++ b/Sources/HashableMacroMacros/Macros/HashableMacro+Hashable.swift @@ -0,0 +1,222 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +extension HashableMacro { + static func expansionForHashable( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext, + propertiesToHash: [TokenSyntax] + ) -> DeclSyntax { + var finalHashInto = true + + if let arguments = node.arguments?.as(LabeledExprListSyntax.self) { + for argument in arguments { + guard let label = argument.label else { continue } + switch label.trimmed.text { + case "finalHashInto": + guard let expression = argument.expression.as(BooleanLiteralExprSyntax.self) else { continue } + switch expression.literal.tokenKind { + case .keyword(.true): + finalHashInto = true + case .keyword(.false): + finalHashInto = false + default: + break + } + default: + break + } + } + } + + let baseModifiers = declaration.modifiers.filter({ modifier in + switch (modifier.name.tokenKind) { + case .keyword(.public): + return true + case .keyword(.internal): + return true + case .keyword(.fileprivate): + return true + case .keyword(.private): + // The added functions should never be private + return false + default: + return false + } + }) + + var hashFunctionModifiers = baseModifiers + if finalHashInto, declaration.is(ClassDeclSyntax.self) { + hashFunctionModifiers.append( + DeclModifierSyntax(name: .keyword(.final)) + ) + } + + let hashFunctionSignature = FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax( + parameters: [ + FunctionParameterSyntax( + firstName: .identifier("into"), + secondName: .identifier("hasher"), + type: AttributedTypeSyntax( + specifier: .keyword(.inout), + baseType: TypeSyntax(stringLiteral: "Hasher") + ) + ), + ] + ) + ) + + let hashFunctionBody = CodeBlockSyntax( + statements: CodeBlockItemListSyntax(itemsBuilder: { + for propertyToken in propertiesToHash { + FunctionCallExprSyntax( + callee: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: "hasher"), + name: .identifier("combine") + ), + argumentList: { + LabeledExprSyntax( + expression: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: .keyword(.`self`)), + name: propertyToken + ) + ) + } + ) + } + }) + ) + + let hashFunction = FunctionDeclSyntax( + modifiers: hashFunctionModifiers, + name: .identifier("hash"), + signature: hashFunctionSignature, + body: hashFunctionBody + ) + + return DeclSyntax(hashFunction) + } + + static func expansionForEquals( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext, + propertiesToHash: [TokenSyntax] + ) throws -> DeclSyntax { + guard let namedDeclaration = declaration as? NamedDeclSyntax else { + throw HashableMacroDiagnosticMessage( + id: "not-named-declaration", + message: "'@Hashable' can only be applied to named declarations", + severity: .error + ) + } + + let baseModifiers = declaration.modifiers.filter({ modifier in + switch (modifier.name.tokenKind) { + case .keyword(.public): + return true + case .keyword(.internal): + return true + case .keyword(.fileprivate): + return true + case .keyword(.private): + // The added functions should never be private + return false + default: + return false + } + }) + + let equalsFunctionSignature = FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax( + parameters: [ + FunctionParameterSyntax( + firstName: .identifier("lhs"), + type: TypeSyntax(stringLiteral: namedDeclaration.name.text), + trailingComma: .commaToken() + ), + FunctionParameterSyntax( + firstName: .identifier("rhs"), + type: TypeSyntax(stringLiteral: namedDeclaration.name.text) + ), + ] + ), + returnClause: ReturnClauseSyntax( + type: IdentifierTypeSyntax(name: .identifier("Bool")) + ) + ) + + var comparisons: InfixOperatorExprSyntax? + + for propertyToken in propertiesToHash { + let comparison = InfixOperatorExprSyntax( + leftOperand: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax( + baseName: .identifier("lhs") + ), + declName: DeclReferenceExprSyntax( + baseName: propertyToken + ) + ), + operator: BinaryOperatorExprSyntax( + operator: .binaryOperator("==") + ), + rightOperand: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax( + baseName: .identifier("rhs") + ), + declName: DeclReferenceExprSyntax( + baseName: propertyToken + ) + ) + ) + + if let existingComparisons = comparisons { + comparisons = InfixOperatorExprSyntax( + leftOperand: existingComparisons, + operator: BinaryOperatorExprSyntax( + leadingTrivia: .newline.appending(Trivia.spaces(8)), + operator: .binaryOperator("&&") + ), + rightOperand: comparison + ) + } else { + comparisons = comparison + } + } + + let equalsBody = CodeBlockSyntax( + statements: CodeBlockItemListSyntax(itemsBuilder: { + if let comparisons { + ReturnStmtSyntax( + leadingTrivia: .spaces(4), + expression: comparisons + ) + } else { + ReturnStmtSyntax( + leadingTrivia: .spaces(4), + expression: BooleanLiteralExprSyntax(booleanLiteral: true) + ) + } + }) + ) + + var equalsFunctionModifiers = baseModifiers + equalsFunctionModifiers.append( + DeclModifierSyntax(name: .keyword(.static)) + ) + + let equalsFunction = FunctionDeclSyntax( + modifiers: equalsFunctionModifiers, + name: .identifier("=="), + signature: equalsFunctionSignature, + body: equalsBody + ) + + return DeclSyntax(equalsFunction) + } +} diff --git a/Sources/HashableMacroMacros/Macros/HashableMacro+NSObjectProtocol.swift b/Sources/HashableMacroMacros/Macros/HashableMacro+NSObjectProtocol.swift new file mode 100644 index 0000000..aa8dfaf --- /dev/null +++ b/Sources/HashableMacroMacros/Macros/HashableMacro+NSObjectProtocol.swift @@ -0,0 +1,344 @@ +#if canImport(ObjectiveC) +import Foundation +import HashableMacroFoundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +extension HashableMacro { + static func expansionForHashProperty( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext, + propertiesToHash: [TokenSyntax] + ) -> DeclSyntax { + let baseModifiers = declaration.modifiers.filter({ modifier in + switch (modifier.name.tokenKind) { + case .keyword(.public): + return true + case .keyword(.internal): + return true + case .keyword(.fileprivate): + return true + case .keyword(.private): + // The added functions should never be private + return false + default: + return false + } + }) + + var hashPropertyModifiers = baseModifiers + hashPropertyModifiers.append( + DeclModifierSyntax(name: .keyword(.override)) + ) + + let hashPropertyDeclaration = VariableDeclSyntax( + modifiers: hashPropertyModifiers, + bindingSpecifier: .keyword(.var), + bindings: PatternBindingListSyntax([ + PatternBindingSyntax( + pattern: IdentifierPatternSyntax(identifier: .identifier("hash")), + typeAnnotation: TypeAnnotationSyntax( + type: IdentifierTypeSyntax(name: .identifier("Int")) + ), + accessorBlock: AccessorBlockSyntax( + accessors: .getter(CodeBlockItemListSyntax(itemsBuilder: { + let havePropertiesToHash = !propertiesToHash.isEmpty + + VariableDeclSyntax( + bindingSpecifier: .keyword(havePropertiesToHash ? .var : .let), + bindings: PatternBindingListSyntax([ + PatternBindingSyntax( + pattern: IdentifierPatternSyntax(identifier: .identifier("hasher")), + initializer: InitializerClauseSyntax( + value: FunctionCallExprSyntax( + calledExpression: DeclReferenceExprSyntax( + baseName: TokenSyntax.identifier("Hasher") + ), + leftParen: .leftParenToken(), + arguments: [], + rightParen: .rightParenToken() + ) + ) + ) + ]) + ) + + for propertyToken in propertiesToHash { + FunctionCallExprSyntax( + callee: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: "hasher"), + name: .identifier("combine") + ), + argumentList: { + LabeledExprSyntax( + expression: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: .keyword(.`self`)), + name: propertyToken + ) + ) + } + ) + } + + ReturnStmtSyntax( + expression: FunctionCallExprSyntax( + calledExpression: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: .identifier("hasher")), + name: .identifier("finalize") + ), + leftParen: .leftParenToken(), + arguments: [], + rightParen: .rightParenToken() + ) + ) + })) + ) + ) + ]) + ) + + return DeclSyntax(hashPropertyDeclaration) + } + + static func expansionForIsEqual( + of node: AttributeSyntax, + providingMembersOf declaration: ClassDeclSyntax, + in context: some MacroExpansionContext, + propertiesToHash: [TokenSyntax], + isEqualToTypeFunctionName: IsEqualToTypeFunctionNameGeneration + ) -> [DeclSyntax] { + let baseModifiers = declaration.modifiers.filter({ modifier in + switch (modifier.name.tokenKind) { + case .keyword(.public), .keyword(.internal), .keyword(.fileprivate), .keyword(.open): + return true + case .keyword(.private): + // The added functions should never be private + return false + default: + return false + } + }) + + var comparisons: InfixOperatorExprSyntax? + + for propertyToken in propertiesToHash { + let comparison = InfixOperatorExprSyntax( + leftOperand: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax( + baseName: .keyword(.`self`) + ), + declName: DeclReferenceExprSyntax( + baseName: propertyToken + ) + ), + operator: BinaryOperatorExprSyntax( + operator: .binaryOperator("==") + ), + rightOperand: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax( + baseName: .identifier("object") + ), + declName: DeclReferenceExprSyntax( + baseName: propertyToken + ) + ) + ) + + if let existingComparisons = comparisons { + comparisons = InfixOperatorExprSyntax( + leftOperand: existingComparisons, + operator: BinaryOperatorExprSyntax( + leadingTrivia: .newline.appending(Trivia.spaces(4)), + operator: .binaryOperator("&&") + ), + rightOperand: comparison + ) + } else { + comparisons = comparison + } + } + + let isEqualAnyBody = CodeBlockSyntax( + statements: CodeBlockItemListSyntax(itemsBuilder: { + GuardStmtSyntax( + conditions: ConditionElementListSyntax { + OptionalBindingConditionSyntax( + bindingSpecifier: .keyword(.let), + pattern: IdentifierPatternSyntax( + identifier: .identifier("object") + ), + initializer: InitializerClauseSyntax( + value: AsExprSyntax( + expression: DeclReferenceExprSyntax(baseName: "object"), + questionOrExclamationMark: .postfixQuestionMarkToken(), + type: IdentifierTypeSyntax( + name: declaration.name + ) + ) + ) + ) + }, + bodyBuilder: { + ReturnStmtSyntax( + leadingTrivia: .spaces(4), + expression: BooleanLiteralExprSyntax(booleanLiteral: false) + ) + } + ) + + GuardStmtSyntax( + conditions: ConditionElementListSyntax { + InfixOperatorExprSyntax( + leftOperand: FunctionCallExprSyntax( + calledExpression: DeclReferenceExprSyntax( + baseName: .identifier("type") + ), + leftParen: .leftParenToken(), + arguments: [ + LabeledExprSyntax( + label: "of", + expression: DeclReferenceExprSyntax( + baseName: .keyword(.`self`) + ) + ) + ], + rightParen: .rightParenToken() + ), + operator: BinaryOperatorExprSyntax( + operator: .binaryOperator("==") + ), + rightOperand: FunctionCallExprSyntax( + calledExpression: DeclReferenceExprSyntax( + baseName: .identifier("type") + ), + leftParen: .leftParenToken(), + arguments: [ + LabeledExprSyntax( + label: "of", + expression: DeclReferenceExprSyntax( + baseName: .identifier("object") + ) + ) + ], + rightParen: .rightParenToken() + ) + ) + }, + bodyBuilder: { + ReturnStmtSyntax( + leadingTrivia: .spaces(4), + expression: BooleanLiteralExprSyntax(booleanLiteral: false) + ) + } + ) + + ReturnStmtSyntax( + expression: FunctionCallExprSyntax( + calledExpression: MemberAccessExprSyntax( + base: DeclReferenceExprSyntax(baseName: .keyword(.`self`)), + declName: DeclReferenceExprSyntax(baseName: .identifier("isEqual")) + ), + leftParen: .leftParenToken(), + arguments: [ + LabeledExprSyntax( + label: "to", + expression: DeclReferenceExprSyntax( + baseName: .identifier("object") + ) + ) + ], + rightParen: .rightParenToken() + ) + ) + }) + ) + + let isEqualTypedBody = CodeBlockSyntax( + statements: CodeBlockItemListSyntax(itemsBuilder: { + if let comparisons { + ReturnStmtSyntax( + expression: comparisons + ) + } else { + ReturnStmtSyntax( + expression: BooleanLiteralExprSyntax(booleanLiteral: true) + ) + } + }) + ) + + var equalsFunctionModifiers = baseModifiers + equalsFunctionModifiers.append( + DeclModifierSyntax(name: .keyword(.override)) + ) + + let isEqualAnyFunction = FunctionDeclSyntax( + modifiers: equalsFunctionModifiers, + name: .identifier("isEqual"), + signature: FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax( + parameters: [ + FunctionParameterSyntax( + firstName: .identifier("_"), + secondName: .identifier("object"), + type: OptionalTypeSyntax(wrappedType: "Any" as TypeSyntax) + ) + ] + ), + returnClause: ReturnClauseSyntax( + type: IdentifierTypeSyntax(name: .identifier("Bool")) + ) + ), + body: isEqualAnyBody + ) + + let objectiveCName: TokenSyntax + switch isEqualToTypeFunctionName { + case .automatic: + objectiveCName = .identifier("isEqualTo\(declaration.name.trimmed):") + case .custom(let customName): + objectiveCName = .identifier(customName) + } + + let isEqualTypedFunction = FunctionDeclSyntax( + attributes: AttributeListSyntax { + .attribute( + AttributeSyntax( + attributeName: IdentifierTypeSyntax(name: .identifier("objc")), + leftParen: .leftParenToken(), + arguments: .objCName([ + ObjCSelectorPieceSyntax(name: objectiveCName), + ]), + rightParen: .rightParenToken(), + trailingTrivia: .newline + ) + ) + }, + modifiers: baseModifiers, + name: .identifier("isEqual"), + signature: FunctionSignatureSyntax( + parameterClause: FunctionParameterClauseSyntax( + parameters: [ + FunctionParameterSyntax( + firstName: .identifier("to"), + secondName: .identifier("object"), + type: IdentifierTypeSyntax(name: declaration.name) + ) + ] + ), + returnClause: ReturnClauseSyntax( + type: IdentifierTypeSyntax(name: .identifier("Bool")) + ) + ), + body: isEqualTypedBody + ) + + return [ + DeclSyntax(isEqualAnyFunction), + DeclSyntax(isEqualTypedFunction), + ] + } +} +#endif diff --git a/Sources/HashableMacroMacros/Macros/HashableMacro.swift b/Sources/HashableMacroMacros/Macros/HashableMacro.swift new file mode 100644 index 0000000..c5da010 --- /dev/null +++ b/Sources/HashableMacroMacros/Macros/HashableMacro.swift @@ -0,0 +1,377 @@ +import Foundation +#if canImport(ObjectiveC) +import HashableMacroFoundation +#endif +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +#if compiler(>=5.9.2) +public struct HashableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + // The macro declares that it can add `NSObjectProtocol`, but this is + // used to check whether the compiler asks for it to be added. If the + // macro is asked to add `NSObjectProtocol` conformance then we know + // this is not an `NSObject` subclass. + #if canImport(ObjectiveC) + var isNSObjectSubclass = true + #endif + + var protocolExtensions: [ExtensionDeclSyntax] = [] + + for protocolType in protocols { + switch protocolType.trimmedDescription { + case "Hashable", "Equatable": + let protocolExtension = ExtensionDeclSyntax( + extendedType: type, + inheritanceClause: InheritanceClauseSyntax( + inheritedTypes: InheritedTypeListSyntax(itemsBuilder: { + InheritedTypeSyntax( + type: protocolType + ) + }) + ), + memberBlock: MemberBlockSyntax(members: "") + ) + protocolExtensions.append(protocolExtension) + #if canImport(ObjectiveC) + case "NSObjectProtocol": + isNSObjectSubclass = false + #endif + default: + throw HashableMacroDiagnosticMessage( + id: "unknown-protocol", + message: "Unknown protocol: '\(protocolType.trimmedDescription)'", + severity: .error + ) + } + } + + let properties = declaration.memberBlock.members.compactMap({ $0.decl.as(VariableDeclSyntax.self) }) + var explicitlyHashedProperties: [TokenSyntax] = [] + var undecoratedProperties: [TokenSyntax] = [] + var notHashedAttributes: [AttributeSyntax] = [] + + for property in properties { + let bindings = property.bindings.compactMap({ binding in + binding + .pattern + .as(IdentifierPatternSyntax.self)? + .identifier + }) + lazy var isCalculated = property.bindings.contains { binding in + guard let accessorBlock = binding.accessorBlock else { return false } + switch accessorBlock.accessors { + case .getter: + return true + case .accessors(let accessors): + for accessor in accessors { + switch accessor.accessorSpecifier.tokenKind { + case .keyword(.get): + return true + default: + break + } + } + } + return false + } + + func attribute(named macroName: String) -> AttributeSyntax? { + for attribute in property.attributes { + guard let attribute = attribute.as(AttributeSyntax.self) else { continue } + let identifier = attribute + .attributeName + .as(IdentifierTypeSyntax.self) + if identifier?.name.tokenKind == .identifier(macroName) { + return attribute + } + } + + return nil + } + + if attribute(named: "Hashed") != nil { + explicitlyHashedProperties.append(contentsOf: bindings) + } else if let notHashedAttribute = attribute(named: "NotHashed") { + notHashedAttributes.append(notHashedAttribute) + } else if !isCalculated { + undecoratedProperties.append(contentsOf: bindings) + } + } + + if !explicitlyHashedProperties.isEmpty { + for notHashedAttribute in notHashedAttributes { + let fixIt = FixIt( + message: HashableMacroFixItMessage( + id: "redundant-not-hashed", + message: "Remove @NotHashed" + ), + changes: [ + FixIt.Change.replace( + oldNode: Syntax(notHashedAttribute), + newNode: Syntax("" as DeclSyntax) + ) + ] + ) + let diagnostic = Diagnostic( + node: Syntax(notHashedAttribute), + message: HashableMacroDiagnosticMessage( + id: "redundant-not-hashed", + message: "The @NotHashed macro is redundant when 1 or more properties are decorated @Hashed. It will be ignored", + severity: .warning + ), + fixIt: fixIt + ) + context.diagnose(diagnostic) + } + } + + let propertiesToHash = !explicitlyHashedProperties.isEmpty ? explicitlyHashedProperties : undecoratedProperties + + #if canImport(ObjectiveC) + #if DEBUG + // The testing library does not process the required protocols and + // passes and empty array for `protocols`. This means that the macro + // assumes that the type conforms to `NSObjectProtocol`. This argument + // cannot be passed in code but it can be passed when the input code is + // written as a string. + if let arguments = node.arguments?.as(LabeledExprListSyntax.self) { + for argument in arguments { + switch argument.label?.trimmed.text { + case "_disableNSObjectSubclassSupport": + guard let expression = argument.expression.as(BooleanLiteralExprSyntax.self) else { continue } + switch expression.literal.tokenKind { + case .keyword(.true): + isNSObjectSubclass = false + default: + break + } + default: + break + } + } + } + #endif + if isNSObjectSubclass { + guard let classDeclaration = declaration as? ClassDeclSyntax else { + throw HashableMacroDiagnosticMessage( + id: "nsobject-subclass-not-class", + message: "This type conforms to 'NSObjectProtocol' but is not a class", + severity: .error + ) + } + + var isEqualToTypeFunctionName: IsEqualToTypeFunctionNameGeneration = .automatic + + if let arguments = node.arguments?.as(LabeledExprListSyntax.self) { + for argument in arguments { + switch argument.label?.trimmedDescription { + case "isEqualToTypeFunctionName": + if let expression = argument.expression.as(MemberAccessExprSyntax.self) { + switch expression.declName.baseName.tokenKind { + case .identifier("automatic"): + isEqualToTypeFunctionName = .automatic + default: + throw HashableMacroDiagnosticMessage( + id: "unknown-isEqualToTypeFunctionName-name", + message: "'\(expression.declName.baseName)' is not a known value for `IsEqualToTypeFunctionNameGeneration`", + severity: .error + ) + } + } else if + let functionExpression = argument + .expression + .as(FunctionCallExprSyntax.self), + let memberAccessExpression = functionExpression + .calledExpression + .as(MemberAccessExprSyntax.self) + { + switch memberAccessExpression.declName.baseName.tokenKind { + case .identifier("custom"): + guard functionExpression.arguments.count == 1 else { + throw HashableMacroDiagnosticMessage( + id: "invalid-isEqualToTypeFunctionName-argument", + message: "Only 1 argument is supported for 'custom'", + severity: .error + ) + } + let nameArgument = functionExpression.arguments.first! + + guard let stringExpression = nameArgument.expression.as(StringLiteralExprSyntax.self) else { + throw HashableMacroDiagnosticMessage( + id: "invalid-isEqualToTypeFunctionName-custom-argument", + message: "Only option for 'custom' must be a string", + severity: .error + ) + } + + let customName = "\(stringExpression.segments)" + + if !customName.hasSuffix(":") { + var newArgument = argument + var functionExpression = functionExpression + functionExpression.arguments[functionExpression.arguments.indices.first!].expression = ExprSyntax(StringLiteralExprSyntax(content: customName + ":")) + newArgument.expression = ExprSyntax(functionExpression) + + let diagnostic = Diagnostic( + node: Syntax(node), + message: HashableMacroDiagnosticMessage( + id: "missing-colon-for-custom-name", + message: "Custom Objective-C function name must end with a colon.", + severity: .error + ), + fixIt: FixIt( + message: HashableMacroFixItMessage( + id: "add-missing-colon-to-custom-name", + message: "Add ':'" + ), + changes: [ + FixIt.Change.replace( + oldNode: Syntax(argument), + newNode: Syntax(newArgument) + ) + ] + ) + ) + context.diagnose(diagnostic) + } + + isEqualToTypeFunctionName = .custom(customName) + default: + throw HashableMacroDiagnosticMessage( + id: "unknown-isEqualToTypeFunctionName-name", + message: "'\(memberAccessExpression.declName.baseName)' is not a known value for `IsEqualToTypeFunctionNameGeneration`", + severity: .error + ) + } + + } else { + throw HashableMacroDiagnosticMessage( + id: "unknown-isEqualToTypeFunctionName-type", + message: "'isEqualToTypeFunctionName' parameter was not of the expected type", + severity: .error + ) + } + default: + break + } + } + } + + let hashPropertyExtension = ExtensionDeclSyntax( + extendedType: type, + memberBlock: MemberBlockSyntax( + members: MemberBlockItemListSyntax(itemsBuilder: { + expansionForHashProperty( + of: node, + providingMembersOf: declaration, + in: context, + propertiesToHash: propertiesToHash + ) + }) + ) + ) + let isEqualImplementationExtension = ExtensionDeclSyntax( + extendedType: type, + memberBlock: MemberBlockSyntax( + members: MemberBlockItemListSyntax( + expansionForIsEqual( + of: node, + providingMembersOf: classDeclaration, + in: context, + propertiesToHash: propertiesToHash, + isEqualToTypeFunctionName: isEqualToTypeFunctionName + ).map { MemberBlockItemSyntax(decl: $0) } + ) + ) + ) + protocolExtensions.append(hashPropertyExtension) + protocolExtensions.append(isEqualImplementationExtension) + } else { + let hashableImplementationExtension = ExtensionDeclSyntax( + extendedType: type, + memberBlock: MemberBlockSyntax( + members: MemberBlockItemListSyntax(itemsBuilder: { + expansionForHashable( + of: node, + providingMembersOf: declaration, + in: context, + propertiesToHash: propertiesToHash + ) + }) + ) + ) + let equatableImplementationExtension = ExtensionDeclSyntax( + extendedType: type, + memberBlock: MemberBlockSyntax( + members: try MemberBlockItemListSyntax(itemsBuilder: { + try expansionForEquals( + of: node, + providingMembersOf: declaration, + in: context, + propertiesToHash: propertiesToHash + ) + }) + ) + ) + protocolExtensions.append(hashableImplementationExtension) + protocolExtensions.append(equatableImplementationExtension) + } + #else + let hashableImplementationExtension = ExtensionDeclSyntax( + extendedType: type, + memberBlock: MemberBlockSyntax( + members: MemberBlockItemListSyntax(itemsBuilder: { + expansionForHashable( + of: node, + providingMembersOf: declaration, + in: context, + propertiesToHash: propertiesToHash + ) + }) + ) + ) + let equatableImplementationExtension = ExtensionDeclSyntax( + extendedType: type, + memberBlock: MemberBlockSyntax( + members: try MemberBlockItemListSyntax(itemsBuilder: { + try expansionForEquals( + of: node, + providingMembersOf: declaration, + in: context, + propertiesToHash: propertiesToHash + ) + }) + ) + ) + protocolExtensions.append(hashableImplementationExtension) + protocolExtensions.append(equatableImplementationExtension) + #endif + + return protocolExtensions + } +} +#else +public struct HashableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + throw HashableMacroDiagnosticMessage( + id: "hashable-macro-unavailable", + message: "'@Hashable' requires Swift 5.9.2 or newer", + severity: .error + ) + } +} +#endif diff --git a/Sources/HashableMacroMacros/Macros/HashedMacro.swift b/Sources/HashableMacroMacros/Macros/HashedMacro.swift new file mode 100644 index 0000000..ea9c27e --- /dev/null +++ b/Sources/HashableMacroMacros/Macros/HashedMacro.swift @@ -0,0 +1,15 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +/// A property that will be included in the implementation of the `Hashable` +/// protocol. +public struct HashedMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + // Only used to decorate members + return [] + } +} diff --git a/Sources/HashableMacroMacros/HashedMacro.swift b/Sources/HashableMacroMacros/Macros/NotHashedMacro.swift similarity index 52% rename from Sources/HashableMacroMacros/HashedMacro.swift rename to Sources/HashableMacroMacros/Macros/NotHashedMacro.swift index 8f115d9..ca6465b 100644 --- a/Sources/HashableMacroMacros/HashedMacro.swift +++ b/Sources/HashableMacroMacros/Macros/NotHashedMacro.swift @@ -1,19 +1,6 @@ import SwiftSyntax import SwiftSyntaxMacros -/// A property that will be included in the implementation of the `Hashable` -/// protocol. -public struct HashedMacro: PeerMacro { - public static func expansion( - of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - // Only used to decorate members - return [] - } -} - /// A property that will be excluded in the implementation of the `Hashable` /// protocol. public struct NotHashedMacro: PeerMacro { diff --git a/Sources/HashableMacroMacros/NSObjectSubclassBehaviour.swift b/Sources/HashableMacroMacros/NSObjectSubclassBehaviour.swift deleted file mode 100644 index d74dd05..0000000 --- a/Sources/HashableMacroMacros/NSObjectSubclassBehaviour.swift +++ /dev/null @@ -1,13 +0,0 @@ -#if canImport(ObjectiveC) -enum NSObjectSubclassBehaviour: Sendable { - /// Never call `super.isEqual(to:)` and do not incorporate `super.hash`. - case neverCallSuper - - /// Call `super.isEqual(to:)` and incorporate `super.hash` only when the - /// type is not a direct subclass of `NSObject`. - case callSuperUnlessDirectSubclass - - /// Always call `super.isEqual(to:)` and incorporate `super.hash`. - case alwaysCallSuper -} -#endif diff --git a/Tests/HashableMacroTests/HashableMacroAPITests.swift b/Tests/HashableMacroTests/HashableMacroAPITests.swift index 5eb2bc0..f42d4f8 100644 --- a/Tests/HashableMacroTests/HashableMacroAPITests.swift +++ b/Tests/HashableMacroTests/HashableMacroAPITests.swift @@ -183,18 +183,15 @@ class NSObjectSubclass: NSObject { } } -@Hashable -class NSObjectSubclassSubclass: NSObjectSubclass { +@Hashable(isEqualToTypeFunctionName: .custom("isEqualToObject:")) +class NSObjectSubclassCustomEqualTo: NSObject { @Hashed - var nsObjectSubclassSubclassProperty: String + var nsObjectSubclassProperty: String init( - nsObjectSubclassProperty: String, - nsObjectSubclassSubclassProperty: String + nsObjectSubclassProperty: String ) { - self.nsObjectSubclassSubclassProperty = nsObjectSubclassSubclassProperty - - super.init(nsObjectSubclassProperty: nsObjectSubclassProperty) + self.nsObjectSubclassProperty = nsObjectSubclassProperty } } #endif diff --git a/Tests/HashableMacroTests/HashableMacroTests.swift b/Tests/HashableMacroTests/HashableMacroTests.swift index 99a50ca..cb6bafa 100644 --- a/Tests/HashableMacroTests/HashableMacroTests.swift +++ b/Tests/HashableMacroTests/HashableMacroTests.swift @@ -1,4 +1,3 @@ -#if compiler(>=5.9.2) import MacroTesting import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport @@ -15,6 +14,7 @@ private let testMacros: [String: Macro.Type] = [ #endif final class HashableMacroTests: XCTestCase { + #if compiler(>=5.9.2) /// Test the usage of the `Hashable` API using a type decorated with the `@Hashable` macro /// that has been expanded by the compiler to check that the expanded implementation is honoured /// when compiled. @@ -148,62 +148,37 @@ final class HashableMacroTests: XCTestCase { func testNSObjectSubclassing() throws { #if canImport(ObjectiveC) - let value1 = NSObjectSubclassSubclass( - nsObjectSubclassProperty: "123", - nsObjectSubclassSubclassProperty: "456" + let value1 = NSObjectSubclass( + nsObjectSubclassProperty: "123" ) - let value2 = NSObjectSubclassSubclass( - nsObjectSubclassProperty: "123-different", - nsObjectSubclassSubclassProperty: "456" + let value2 = NSObjectSubclass( + nsObjectSubclassProperty: "123-different" ) - let value3 = NSObjectSubclassSubclass( - nsObjectSubclassProperty: "123", - nsObjectSubclassSubclassProperty: "456-different" - ) - let value4 = NSObjectSubclassSubclass( - nsObjectSubclassProperty: "123-different", - nsObjectSubclassSubclassProperty: "456-different" - ) - let value5 = NSObjectSubclass(nsObjectSubclassProperty: "123") - let value6 = NSObjectSubclassSubclass( - nsObjectSubclassProperty: "123", - nsObjectSubclassSubclassProperty: "456" + let value3 = NSObjectSubclass( + nsObjectSubclassProperty: "123" ) XCTAssertEqual(value1, value1) XCTAssertEqual(value1.hashValue, value1.hashValue) - XCTAssertEqual(value1, value6) - XCTAssertEqual(value1.hashValue, value6.hashValue) - XCTAssertEqual(value2, value2) - XCTAssertEqual(value2.hashValue, value2.hashValue) - XCTAssertEqual(value3, value3) - XCTAssertEqual(value3.hashValue, value3.hashValue) - XCTAssertEqual(value4, value4) - XCTAssertEqual(value4.hashValue, value4.hashValue) - XCTAssertEqual(value5, value5) - XCTAssertEqual(value5.hashValue, value5.hashValue) XCTAssertNotEqual(value1, value2) - XCTAssertNotEqual(value1, value3) - XCTAssertNotEqual(value1, value4) - XCTAssertNotEqual(value1, value5) XCTAssertNotEqual(value1.hashValue, value2.hashValue) - XCTAssertNotEqual(value1.hashValue, value3.hashValue) - XCTAssertNotEqual(value1.hashValue, value4.hashValue) - XCTAssertNotEqual(value1.hashValue, value5.hashValue) + XCTAssertEqual(value1, value3) + XCTAssertEqual(value1.hashValue, value3.hashValue) + + XCTAssertEqual(value2, value2) + XCTAssertEqual(value2.hashValue, value2.hashValue) + XCTAssertNotEqual(value2, value1) + XCTAssertNotEqual(value2.hashValue, value1.hashValue) XCTAssertNotEqual(value2, value3) - XCTAssertNotEqual(value2, value4) - XCTAssertNotEqual(value2, value5) XCTAssertNotEqual(value2.hashValue, value3.hashValue) - XCTAssertNotEqual(value2.hashValue, value4.hashValue) - XCTAssertNotEqual(value2.hashValue, value5.hashValue) - XCTAssertNotEqual(value3, value4) - XCTAssertNotEqual(value3, value5) - XCTAssertNotEqual(value3.hashValue, value4.hashValue) - XCTAssertNotEqual(value3.hashValue, value5.hashValue) - XCTAssertNotEqual(value5, value1) - XCTAssertNotEqual(value5, value2) - XCTAssertNotEqual(value5, value3) - XCTAssertNotEqual(value5, value4) + + XCTAssertEqual(value3, value3) + XCTAssertEqual(value3.hashValue, value3.hashValue) + XCTAssertNotEqual(value3, value2) + XCTAssertNotEqual(value3.hashValue, value2.hashValue) + XCTAssertEqual(value3, value1) + XCTAssertEqual(value3.hashValue, value1.hashValue) + #else throw XCTSkip("NSObject detection is only possible when ObjectiveC is available") #endif @@ -793,7 +768,7 @@ final class HashableMacroTests: XCTestCase { #endif } - func testDirectNSObjectSubclass() throws { + func testNSObjectSubclass_implicitAutomaticCustomEqualToTypeFunctionName() throws { #if canImport(HashableMacroMacros) #if canImport(ObjectiveC) assertMacro(testMacros) { @@ -829,15 +804,16 @@ final class HashableMacroTests: XCTestCase { extension TestClass { override func isEqual(_ object: Any?) -> Bool { - guard let object else { + guard let object = object as? TestClass else { return false } guard type(of: self) == type(of: object) else { return false } - guard let object = object as? Self else { - return false - } + return self.isEqual(to: object) + } + @objc(isEqualToTestClass:) + func isEqual(to object: TestClass) -> Bool { return self.hashedProperty == object.hashedProperty && self.secondHashedProperty == object.secondHashedProperty } @@ -852,16 +828,19 @@ final class HashableMacroTests: XCTestCase { #endif } - func testDirectNSObjectSubclass_neverCallSuper() throws { + func testNSObjectSubclass_explicitAutomaticCustomEqualToTypeFunctionName() throws { #if canImport(HashableMacroMacros) #if canImport(ObjectiveC) assertMacro(testMacros) { """ - @Hashable(_disableNSObjectSubclassSupport: false, nsObjectSubclassBehaviour: .neverCallSuper) + @Hashable(_disableNSObjectSubclassSupport: false, isEqualToTypeFunctionName: .automatic) class TestClass: NSObject { @Hashed var hashedProperty: String + @Hashed + var secondHashedProperty: String + var notHashedProperty: String } """ @@ -869,6 +848,7 @@ final class HashableMacroTests: XCTestCase { """ class TestClass: NSObject { var hashedProperty: String + var secondHashedProperty: String var notHashedProperty: String } @@ -877,22 +857,25 @@ final class HashableMacroTests: XCTestCase { override var hash: Int { var hasher = Hasher() hasher.combine(self.hashedProperty) + hasher.combine(self.secondHashedProperty) return hasher.finalize() } } extension TestClass { override func isEqual(_ object: Any?) -> Bool { - guard let object else { + guard let object = object as? TestClass else { return false } guard type(of: self) == type(of: object) else { return false } - guard let object = object as? Self else { - return false - } + return self.isEqual(to: object) + } + @objc(isEqualToTestClass:) + func isEqual(to object: TestClass) -> Bool { return self.hashedProperty == object.hashedProperty + && self.secondHashedProperty == object.secondHashedProperty } } """ @@ -905,12 +888,12 @@ final class HashableMacroTests: XCTestCase { #endif } - func testDirectNSObjectSubclass_callSuperUnlessDirectSubclass() throws { + func testNSObjectSubclass_validCustomEqualToTypeFunctionName() throws { #if canImport(HashableMacroMacros) #if canImport(ObjectiveC) assertMacro(testMacros) { """ - @Hashable(_disableNSObjectSubclassSupport: false, nsObjectSubclassBehaviour: .callSuperUnlessDirectSubclass) + @Hashable(_disableNSObjectSubclassSupport: false, isEqualToTypeFunctionName: .custom("myCustomName:")) class TestClass: NSObject { @Hashed var hashedProperty: String @@ -936,15 +919,16 @@ final class HashableMacroTests: XCTestCase { extension TestClass { override func isEqual(_ object: Any?) -> Bool { - guard let object else { + guard let object = object as? TestClass else { return false } guard type(of: self) == type(of: object) else { return false } - guard let object = object as? Self else { - return false - } + return self.isEqual(to: object) + } + @objc(myCustomName:) + func isEqual(to object: TestClass) -> Bool { return self.hashedProperty == object.hashedProperty } } @@ -958,12 +942,12 @@ final class HashableMacroTests: XCTestCase { #endif } - func testDirectNSObjectSubclass_alwaysCallSuper() throws { + func testNSObjectSubclass_invalidCustomEqualToTypeFunctionName() throws { #if canImport(HashableMacroMacros) #if canImport(ObjectiveC) assertMacro(testMacros) { """ - @Hashable(_disableNSObjectSubclassSupport: false, nsObjectSubclassBehaviour: .alwaysCallSuper) + @Hashable(_disableNSObjectSubclassSupport: false, isEqualToTypeFunctionName: .custom("myCustomName")) class TestClass: NSObject { @Hashed var hashedProperty: String @@ -971,114 +955,23 @@ final class HashableMacroTests: XCTestCase { var notHashedProperty: String } """ - } expansion: { + } diagnostics: { """ + @Hashable(_disableNSObjectSubclassSupport: false, isEqualToTypeFunctionName: .custom("myCustomName")) + ┬──────────────────────────────────────────────────────────────────────────────────────────────────── + ╰─ 🛑 Custom Objective-C function name must end with a colon. + ✏️ Add ':' class TestClass: NSObject { - var hashedProperty: String - - var notHashedProperty: String - } - - extension TestClass { - override var hash: Int { - var hasher = Hasher() - hasher.combine(super.hash) - hasher.combine(self.hashedProperty) - return hasher.finalize() - } - } - - extension TestClass { - override func isEqual(_ object: Any?) -> Bool { - guard let object else { - return false - } - guard type(of: self) == type(of: object) else { - return false - } - guard super.isEqual(object) else { - return false - } - guard let object = object as? Self else { - return false - } - return self.hashedProperty == object.hashedProperty - } - } - """ - } - #else - throw XCTSkip("This expansion requires Objective-C") - #endif - #else - throw XCTSkip("Macros are only supported when running tests for the host platform") - #endif - } - - func testIndirectNSObjectSubclass() throws { - #if canImport(HashableMacroMacros) - #if canImport(ObjectiveC) - assertMacro(testMacros) { - """ - @Hashable(_disableNSObjectSubclassSupport: false) - class TestClass: UIView { @Hashed var hashedProperty: String var notHashedProperty: String } """ - } expansion: { - """ - class TestClass: UIView { - var hashedProperty: String - - var notHashedProperty: String - } - - extension TestClass { - override var hash: Int { - var hasher = Hasher() - hasher.combine(super.hash) - hasher.combine(self.hashedProperty) - return hasher.finalize() - } - } - - extension TestClass { - override func isEqual(_ object: Any?) -> Bool { - guard let object else { - return false - } - guard type(of: self) == type(of: object) else { - return false - } - guard super.isEqual(object) else { - return false - } - guard let object = object as? Self else { - return false - } - return self.hashedProperty == object.hashedProperty - } - } - """ - } - #else - throw XCTSkip("This expansion requires Objective-C") - #endif - #else - throw XCTSkip("Macros are only supported when running tests for the host platform") - #endif - } - - func testIndirectNSObjectSubclass_neverCallSuper() throws { - #if canImport(HashableMacroMacros) - #if canImport(ObjectiveC) - assertMacro(testMacros) { + } fixes: { """ - @Hashable(_disableNSObjectSubclassSupport: false, nsObjectSubclassBehaviour: .neverCallSuper) - class TestClass: UIView { + @Hashable(_disableNSObjectSubclassSupport: false, isEqualToTypeFunctionName: .custom("myCustomName:")) + class TestClass: NSObject { @Hashed var hashedProperty: String @@ -1087,7 +980,7 @@ final class HashableMacroTests: XCTestCase { """ } expansion: { """ - class TestClass: UIView { + class TestClass: NSObject { var hashedProperty: String var notHashedProperty: String @@ -1103,72 +996,16 @@ final class HashableMacroTests: XCTestCase { extension TestClass { override func isEqual(_ object: Any?) -> Bool { - guard let object else { + guard let object = object as? TestClass else { return false } guard type(of: self) == type(of: object) else { return false } - guard let object = object as? Self else { - return false - } - return self.hashedProperty == object.hashedProperty - } - } - """ - } - #else - throw XCTSkip("This expansion requires Objective-C") - #endif - #else - throw XCTSkip("Macros are only supported when running tests for the host platform") - #endif - } - - func testIndirectNSObjectSubclass_callSuperUnlessDirectSubclass() throws { - #if canImport(HashableMacroMacros) - #if canImport(ObjectiveC) - assertMacro(testMacros) { - """ - @Hashable(_disableNSObjectSubclassSupport: false, nsObjectSubclassBehaviour: .callSuperUnlessDirectSubclass) - class TestClass: UIView { - @Hashed - var hashedProperty: String - - var notHashedProperty: String - } - """ - } expansion: { - """ - class TestClass: UIView { - var hashedProperty: String - - var notHashedProperty: String - } - - extension TestClass { - override var hash: Int { - var hasher = Hasher() - hasher.combine(super.hash) - hasher.combine(self.hashedProperty) - return hasher.finalize() + return self.isEqual(to: object) } - } - - extension TestClass { - override func isEqual(_ object: Any?) -> Bool { - guard let object else { - return false - } - guard type(of: self) == type(of: object) else { - return false - } - guard super.isEqual(object) else { - return false - } - guard let object = object as? Self else { - return false - } + @objc(myCustomName:) + func isEqual(to object: TestClass) -> Bool { return self.hashedProperty == object.hashedProperty } } @@ -1181,62 +1018,27 @@ final class HashableMacroTests: XCTestCase { throw XCTSkip("Macros are only supported when running tests for the host platform") #endif } - - func testIndirectNSObjectSubclass_alwaysCallSuper() throws { + #else + func testUnavailableSwift5_9_2() throws { #if canImport(HashableMacroMacros) - #if canImport(ObjectiveC) assertMacro(testMacros) { """ - @Hashable(_disableNSObjectSubclassSupport: false, nsObjectSubclassBehaviour: .alwaysCallSuper) - class TestClass: UIView { - @Hashed - var hashedProperty: String - - var notHashedProperty: String + @Hashable + struct Test { } """ - } expansion: { + } diagnostics: { """ - class TestClass: UIView { - var hashedProperty: String - - var notHashedProperty: String - } - - extension TestClass { - override var hash: Int { - var hasher = Hasher() - hasher.combine(super.hash) - hasher.combine(self.hashedProperty) - return hasher.finalize() - } - } - - extension TestClass { - override func isEqual(_ object: Any?) -> Bool { - guard let object else { - return false - } - guard type(of: self) == type(of: object) else { - return false - } - guard super.isEqual(object) else { - return false - } - guard let object = object as? Self else { - return false - } - return self.hashedProperty == object.hashedProperty - } + @Hashable + ┬──────── + ╰─ 🛑 '@Hashable' requires Swift 5.9.2 or newer + struct Test { } """ } #else - throw XCTSkip("This expansion requires Objective-C") - #endif - #else throw XCTSkip("Macros are only supported when running tests for the host platform") #endif } + #endif } -#endif diff --git a/codecov.yml b/codecov.yml index 3ea489c..846d825 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,3 @@ ignore: - - "HashableMacro/Sources/HashableMacroMacros/CustomHashablePlugin.swift" - - "HashableMacro/Tests/HashableMacroTests/HashableMacroAPITests.swift" + - "Sources/HashableMacroMacros/CustomHashablePlugin.swift" + - "Tests/HashableMacroTests/HashableMacroAPITests.swift"