Skip to content

Commit

Permalink
equatable macro
Browse files Browse the repository at this point in the history
  • Loading branch information
ay42 committed May 28, 2024
1 parent 6fadab9 commit 06a9e89
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 31 deletions.
15 changes: 15 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 17 additions & 6 deletions Sources/Equatable/EquatableMacro.swift
Original file line number Diff line number Diff line change
@@ -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")
15 changes: 11 additions & 4 deletions Sources/EquatableClient/main.swift
Original file line number Diff line number Diff line change
@@ -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)")
50 changes: 40 additions & 10 deletions Sources/EquatableMacros/EquatableExtensionMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
115 changes: 104 additions & 11 deletions Tests/EquatableTests/EquatableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,129 @@ 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
)
#else
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
Expand Down

0 comments on commit 06a9e89

Please sign in to comment.