From 06a9e89b21ed08f269c4d3997427a02604ebe7d8 Mon Sep 17 00:00:00 2001 From: Alexander Young Date: Tue, 28 May 2024 23:49:13 +0700 Subject: [PATCH] equatable macro --- Package.resolved | 15 +++ Sources/Equatable/EquatableMacro.swift | 23 +++- Sources/EquatableClient/main.swift | 15 ++- .../EquatableExtensionMacro.swift | 50 ++++++-- Tests/EquatableTests/EquatableTests.swift | 115 ++++++++++++++++-- 5 files changed, 187 insertions(+), 31 deletions(-) create mode 100644 Package.resolved diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..9e4e8e8 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "dd25aeaaf4e3c7cfdf3331d9ffc34f44dbd75c0585bfcf9d8f0bb0c620d577f1", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 3 +} diff --git a/Sources/Equatable/EquatableMacro.swift b/Sources/Equatable/EquatableMacro.swift index 030475d..8b42c12 100644 --- a/Sources/Equatable/EquatableMacro.swift +++ b/Sources/Equatable/EquatableMacro.swift @@ -1,8 +1,19 @@ -/// A macro that produces both a value and a string containing the -/// source code that generated the value. For example, +/// This macro allows you to easily generate the `==` operator for object types, reducing boilerplate code. /// -/// #stringify(x + y) +/// Here is how you can use the `Equatable` macro: /// -/// produces a tuple `(x + y, "x + y")`. -@attached(extension, conformances: Equatable) -public macro equatable() = #externalMacro(module: "MacroExamplesImplementation", type: "EquatableExtensionMacro") +/// ```swift +/// @Equatable +/// final class MyClass { +/// var x: Int +/// var y: Int +/// } +/// +/// let a = MyClass(x: 1, y: 2) +/// let b = MyClass(x: 1, y: 2) +/// +/// // Now you can use the == operator +/// assert(a == b) +/// ``` +@attached(extension, conformances: Equatable, names: named(==)) +public macro Equatable() = #externalMacro(module: "EquatableMacros", type: "EquatableExtensionMacro") diff --git a/Sources/EquatableClient/main.swift b/Sources/EquatableClient/main.swift index 5ef1be5..99f8525 100644 --- a/Sources/EquatableClient/main.swift +++ b/Sources/EquatableClient/main.swift @@ -1,8 +1,15 @@ import Equatable -let a = 17 -let b = 25 +@Equatable +final class Planet { + let name: String + + init(name: String) { + self.name = name + } +} -let (result, code) = #stringify(a + b) +let planet1 = Planet(name: "Mars") +let planet2 = Planet(name: "Venus") -print("The value \(result) was produced by the code \"\(code)\"") +print("The value \(planet1 == planet2)") diff --git a/Sources/EquatableMacros/EquatableExtensionMacro.swift b/Sources/EquatableMacros/EquatableExtensionMacro.swift index a37646e..70dfbae 100644 --- a/Sources/EquatableMacros/EquatableExtensionMacro.swift +++ b/Sources/EquatableMacros/EquatableExtensionMacro.swift @@ -3,17 +3,47 @@ import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros -/// Implementation of the `stringify` macro, which takes an expression -/// of any type and produces a tuple containing the value of that expression -/// and the source code that produced the value. For example -/// -/// #stringify(x + y) -/// -/// will expand to -/// -/// (x + y, "x + y") -public enum EquatableExtensionMacro: ExtensionMacro { +public enum EquatableExtensionError: CustomStringConvertible, Error { + case onlyApplicableToFinalClassOrActor + public var description: String { + switch self { + case .onlyApplicableToFinalClassOrActor: + "@Equatable can only be applied to final class or actor" + } + } +} + +public enum EquatableExtensionMacro: ExtensionMacro { + public static func expansion(of node: SwiftSyntax.AttributeSyntax, attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, conformingTo protocols: [SwiftSyntax.TypeSyntax], in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.ExtensionDeclSyntax] { + + guard [.classDecl, .actorDecl].contains(declaration.kind) else { + throw EquatableExtensionError.onlyApplicableToFinalClassOrActor + } + + if case .classDecl = declaration.kind, !declaration.modifiers.contains(where: { $0.name.tokenKind == .keyword(.final) }) { + throw EquatableExtensionError.onlyApplicableToFinalClassOrActor + } + + return try [ + ExtensionDeclSyntax("extension \(type.trimmed): Equatable") { + try FunctionDeclSyntax("static func == (lhs: \(type.trimmed), rhs: \(type.trimmed)) -> Bool") { + let properties = declaration.memberBlock.members + .compactMap { $0.decl.as(VariableDeclSyntax.self) } + .compactMap { $0.bindings.first?.as(PatternBindingSyntax.self) } + .filter { $0.accessorBlock == nil } + .compactMap { $0.pattern.as(IdentifierPatternSyntax.self) } + .map { $0.identifier.text } + + for (index, property) in properties.enumerated() { + let addOperator = index == properties.indices.last ? "" : " &&" + "lhs.\(raw: property) == rhs.\(raw: property)\(raw: addOperator)" + } + } + } + ] + + } } @main diff --git a/Tests/EquatableTests/EquatableTests.swift b/Tests/EquatableTests/EquatableTests.swift index d1cd35d..11ff29f 100644 --- a/Tests/EquatableTests/EquatableTests.swift +++ b/Tests/EquatableTests/EquatableTests.swift @@ -9,19 +9,46 @@ import XCTest import EquatableMacros let testMacros: [String: Macro.Type] = [ - "stringify": StringifyMacro.self, + "Equatable": EquatableExtensionMacro.self, ] #endif final class EquatableTests: XCTestCase { - func testMacro() throws { + func testEquatableMacroOnFinalClasses() throws { #if canImport(EquatableMacros) + assertMacroExpansion( """ - #stringify(a + b) + @Equatable + final class Planet { + let name: String + let mass: Mass + + var this: String { "1" } + + init(name: String) { + self.name = name + } + } """, expandedSource: """ - (a + b, "a + b") + final class Planet { + let name: String + let mass: Mass + + var this: String { "1" } + + init(name: String) { + self.name = name + } + } + + extension Planet: Equatable { + static func == (lhs: Planet, rhs: Planet) -> Bool { + lhs.name == rhs.name && + lhs.mass == rhs.mass + } + } """, macros: testMacros ) @@ -29,16 +56,82 @@ final class EquatableTests: XCTestCase { throw XCTSkip("macros are only supported when running tests for the host platform") #endif } + + func testEquatableMacroOnActors() throws { + #if canImport(EquatableMacros) + + assertMacroExpansion( + """ + @Equatable + actor Planet { + let name: String + let mass: Mass + + var this: String { "1" } + + init(name: String) { + self.name = name + } + } + """, + expandedSource: """ + actor Planet { + let name: String + let mass: Mass + + var this: String { "1" } + + init(name: String) { + self.name = name + } + } - func testMacroWithStringLiteral() throws { + extension Planet: Equatable { + static func == (lhs: Planet, rhs: Planet) -> Bool { + lhs.name == rhs.name && + lhs.mass == rhs.mass + } + } + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } + + func testEquatableMacroOnStructs() throws { #if canImport(EquatableMacros) + assertMacroExpansion( - #""" - #stringify("Hello, \(name)") - """#, - expandedSource: #""" - ("Hello, \(name)", #""Hello, \(name)""#) - """#, + """ + @Equatable + struct Planet { + let name: String + let mass: Mass + + var this: String { "1" } + + init(name: String) { + self.name = name + } + } + """, + expandedSource: """ + struct Planet { + let name: String + let mass: Mass + + var this: String { "1" } + + init(name: String) { + self.name = name + } + } + """, + diagnostics: [ + DiagnosticSpec(message: "@Equatable can only be applied to final class or actor", line: 1, column: 1) + ], macros: testMacros ) #else