Skip to content

Commit d78d51e

Browse files
feat(patch): generate correct implementation if type conforms to (#13)
1 parent 0457392 commit d78d51e

File tree

11 files changed

+285
-109
lines changed

11 files changed

+285
-109
lines changed

Package.resolved

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ let package = Package(
1515
)
1616
],
1717
dependencies: [
18-
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.1"),
18+
.package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.0"..<"602.0.0"),
1919
.package(url: "https://github.com/pointfreeco/swift-macro-testing.git", from: "0.6.3")
2020
],
2121
targets: [

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,34 @@ struct ViewTakesClosure: View {
139139
In this example `ViewTakesClosure`'s closure captures the `enabled` value on callsite and since it's marked with `@EquatableIgnoredUnsafeClosure`
140140
it will not cause a re-render when the value of `enabled` changes. The closure will always print the initial value of `enabled` which is an incorrect behavior.
141141

142-
## References
142+
## Hashable conformance
143+
144+
If the type is marked as conforming to `Hashable` the compiler synthesized `Hashable` implementation will not be correct. That's why the `@Equatable` macro will also generate a `Hashable` implementation for the type that is aligned with the `Equatable` implementation.
145+
146+
```swift
147+
import Equatable
148+
@Equatable
149+
struct User: Hashable {
150+
151+
let id: Int
152+
153+
@EquatableIgnored var name = ""
154+
}
155+
```
143156

144-
This package is inspired by Cal Stephens' blog post [Understanding and Improving SwiftUI Performance](https://medium.com/airbnb-engineering/understanding-and-improving-swiftui-performance-37b77ac61896).
157+
Expanded:
158+
```swift
159+
extension User: Equatable {
160+
nonisolated public static func == (lhs: User, rhs: User) -> Bool {
161+
lhs.id == rhs.id
162+
}
163+
}
164+
extension User {
165+
nonisolated public func hash(into hasher: inout Hasher) {
166+
hasher.combine(id)
167+
}
168+
}
169+
```
170+
171+
## References
172+
This package is inspired by Cal Stephen's & Miguel Jimenez's blog post [Understanding and Improving SwiftUI Performance](https://medium.com/airbnb-engineering/understanding-and-improving-swiftui-performance-37b77ac61896).

Sources/Equatable/Equatable.swift

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,34 @@
4040
/// lhs.id == rhs.id && lhs.username == rhs.username
4141
/// }
4242
/// }
43+
/// ```
44+
///
45+
/// If the type is marked as conforming to `Hashable` the compiler synthesized `Hashable` implementation will not be correct.
46+
/// That's why the `@Equatable` macro will also generate a `Hashable` implementation for the type that is aligned with the `Equatable` implementation.
47+
///
48+
/// ```swift
49+
/// import Equatable
50+
/// @Equatable
51+
/// struct User: Hashable {
52+
/// let id: Int
53+
/// @EquatableIgnored var name = ""
54+
/// }
55+
/// ```
4356
///
44-
@attached(extension, conformances: Equatable, names: named(==))
57+
/// Expanded:
58+
/// ```swift
59+
/// extension User: Equatable {
60+
/// nonisolated public static func == (lhs: User, rhs: User) -> Bool {
61+
/// lhs.id == rhs.id
62+
/// }
63+
/// }
64+
/// extension User {
65+
/// nonisolated public func hash(into hasher: inout Hasher) {
66+
/// hasher.combine(id)
67+
/// }
68+
/// }
69+
/// ```
70+
@attached(extension, conformances: Equatable, Hashable, names: named(==), named(hash(into:)))
4571
public macro Equatable() = #externalMacro(module: "EquatableMacros", type: "EquatableMacro")
4672

4773
/// A peer macro that marks properties to be ignored in `Equatable` conformance generation.

Sources/EquatableClient/main.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,11 @@ struct ProfileView: View {
121121
}
122122
}
123123
}
124+
125+
@Equatable
126+
struct User: Hashable {
127+
let id: Int
128+
@EquatableIgnored var name = ""
129+
}
130+
124131
#endif

Sources/EquatableMacros/EquatableMacro.swift

Lines changed: 91 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import SwiftSyntaxMacros
88
/// A macro that automatically generates an `Equatable` conformance for structs.
99
///
1010
/// This macro creates a standard equality implementation by comparing all stored properties
11-
/// that aren't explicitly marked to be skipped with `@EquatableIgnored`. Properties with SwiftUI property wrappers
12-
/// (like `@State`, `@ObservedObject`, etc.)
11+
/// that aren't explicitly marked to be skipped with `@EquatableIgnored`.
12+
/// Properties with SwiftUI property wrappers (like `@State`, `@ObservedObject`, etc.)
1313
///
1414
/// Structs with arbitary closures are not supported unless they are marked explicitly with `@EquatableIgnoredUnsafeClosure` -
1515
/// meaning that they are safe because they don't influence rendering of the view's body.
1616
///
1717
/// Usage:
1818
/// ```swift
19+
/// import Equatable
1920
/// import SwiftUI
2021
///
2122
/// @Equatable
@@ -46,7 +47,33 @@ import SwiftSyntaxMacros
4647
/// lhs.id == rhs.id && lhs.username == rhs.username
4748
/// }
4849
/// }
50+
/// ```
51+
///
52+
/// If the type is marked as conforming to `Hashable` the compiler synthesized `Hashable` implementation will not be correct.
53+
/// That's why the `@Equatable` macro will also generate a `Hashable` implementation for the type that is aligned with the `Equatable` implementation.
54+
///
55+
/// ```swift
56+
/// import Equatable
57+
/// @Equatable
58+
/// struct User: Hashable {
59+
/// let id: Int
60+
/// @EquatableIgnored var name = ""
61+
/// }
62+
/// ```
4963
///
64+
/// Expanded:
65+
/// ```swift
66+
/// extension User: Equatable {
67+
/// nonisolated public static func == (lhs: User, rhs: User) -> Bool {
68+
/// lhs.id == rhs.id
69+
/// }
70+
/// }
71+
/// extension User {
72+
/// nonisolated public func hash(into hasher: inout Hasher) {
73+
/// hasher.combine(id)
74+
/// }
75+
/// }
76+
/// ```
5077
public struct EquatableMacro: ExtensionMacro {
5178
private static let skippablePropertyWrappers: Set = [
5279
"State",
@@ -141,25 +168,28 @@ public struct EquatableMacro: ExtensionMacro {
141168
return []
142169
}
143170

144-
let comparisons = sortedProperties.map { property in
145-
"lhs.\(property.name) == rhs.\(property.name)"
146-
}.joined(separator: " && ")
147-
148-
let equalityImplementation = comparisons.isEmpty ? "true" : comparisons
171+
guard let extensionSyntax = Self.generateEquatableExtensionSyntax(
172+
sortedProperties: sortedProperties,
173+
type: type
174+
) else {
175+
return []
176+
}
149177

150-
let extensionDecl: DeclSyntax = """
151-
extension \(type): Equatable {
152-
nonisolated public static func == (lhs: \(type), rhs: \(type)) -> Bool {
153-
\(raw: equalityImplementation)
178+
// Check if the type conforms to `Hashable`
179+
if Self.isHashable(structDecl) {
180+
// If the type conforms to `Hashable` we need to generate the `Hashable` conformance to match
181+
// the properties used in `Equatable` implementation
182+
guard let hashableExtensionSyntax = Self.generateHashableExtensionSyntax(
183+
sortedProperties: sortedProperties,
184+
type: type
185+
) else {
186+
return [extensionSyntax]
154187
}
155-
}
156-
"""
157188

158-
guard let extensionSyntax = extensionDecl.as(ExtensionDeclSyntax.self) else {
159-
return []
189+
return [extensionSyntax, hashableExtensionSyntax]
190+
} else {
191+
return [extensionSyntax]
160192
}
161-
162-
return [extensionSyntax]
163193
}
164194

165195
// Skip properties with SwiftUI attributes (like @State, @Binding, etc.) or if they are marked with @EqutableIgnored
@@ -209,6 +239,13 @@ public struct EquatableMacro: ExtensionMacro {
209239
}
210240
}
211241

242+
private static func isHashable(_ structDecl: StructDeclSyntax) -> Bool {
243+
let existingConformances = structDecl.inheritanceClause?.inheritedTypes
244+
.compactMap { $0.type.as(IdentifierTypeSyntax.self)?.name.text }
245+
?? []
246+
return existingConformances.contains("Hashable")
247+
}
248+
212249
private static func isMarkedWithEquatableIgnoredUnsafeClosure(_ varDecl: VariableDeclSyntax) -> Bool {
213250
varDecl.attributes.contains(where: { attribute in
214251
if let attributeName = attribute.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text {
@@ -288,7 +325,7 @@ public struct EquatableMacro: ExtensionMacro {
288325
Consider marking the closure with\
289326
@EquatableIgnoredUnsafeClosure if it doesn't effect the view's body output.
290327
""",
291-
fixItID: .init(
328+
fixItID: MessageID(
292329
domain: "",
293330
id: "test"
294331
)
@@ -300,104 +337,54 @@ public struct EquatableMacro: ExtensionMacro {
300337

301338
return diagnostic
302339
}
303-
}
304340

305-
@main
306-
struct EquatablePlugin: CompilerPlugin {
307-
let providingMacros: [Macro.Type] = [
308-
EquatableMacro.self,
309-
EquatableIgnoredMacro.self,
310-
EquatableIgnoredUnsafeClosureMacro.self
311-
]
312-
}
313-
314-
struct SimpleFixItMessage: FixItMessage {
315-
let message: String
316-
let fixItID: MessageID
317-
}
318-
319-
extension TypeSyntax {
320-
var isSwift: Bool {
321-
if self.as(IdentifierTypeSyntax.self)?.isSwift ?? false {
322-
return true
323-
}
324-
return false
325-
}
326-
}
341+
private static func generateEquatableExtensionSyntax(
342+
sortedProperties: [(name: String, type: TypeSyntax?)],
343+
type: TypeSyntaxProtocol
344+
) -> ExtensionDeclSyntax? {
345+
let comparisons = sortedProperties.map { property in
346+
"lhs.\(property.name) == rhs.\(property.name)"
347+
}.joined(separator: " && ")
327348

328-
extension TypeSyntax {
329-
var isArray: Bool {
330-
if self.is(ArrayTypeSyntax.self) {
331-
return true
332-
}
333-
if self.as(IdentifierTypeSyntax.self)?.isArray ?? false {
334-
return true
335-
}
336-
if self.as(MemberTypeSyntax.self)?.isArray ?? false {
337-
return true
338-
}
339-
return false
340-
}
341-
}
349+
let equalityImplementation = comparisons.isEmpty ? "true" : comparisons
342350

343-
extension IdentifierTypeSyntax {
344-
var isSwift: Bool {
345-
if self.name.text == "Swift" {
346-
return true
351+
let extensionDecl: DeclSyntax = """
352+
extension \(type): Equatable {
353+
nonisolated public static func == (lhs: \(type), rhs: \(type)) -> Bool {
354+
\(raw: equalityImplementation)
355+
}
347356
}
348-
return false
349-
}
350-
}
357+
"""
351358

352-
extension IdentifierTypeSyntax {
353-
var isArray: Bool {
354-
if self.name.text == "Array" {
355-
return true
356-
}
357-
return false
359+
return extensionDecl.as(ExtensionDeclSyntax.self)
358360
}
359-
}
360361

361-
extension IdentifierTypeSyntax {
362-
var isDictionary: Bool {
363-
if self.name.text == "Dictionary" {
364-
return true
362+
private static func generateHashableExtensionSyntax(
363+
sortedProperties: [(name: String, type: TypeSyntax?)],
364+
type: TypeSyntaxProtocol
365+
) -> ExtensionDeclSyntax? {
366+
let hashableImplementation = sortedProperties.map { property in
367+
"hasher.combine(\(property.name))"
365368
}
366-
return false
367-
}
368-
}
369+
.joined(separator: "\n")
369370

370-
extension MemberTypeSyntax {
371-
var isArray: Bool {
372-
if self.baseType.isSwift,
373-
self.name.text == "Array" {
374-
return true
371+
let hashableExtensionDecl: DeclSyntax = """
372+
extension \(raw: type) {
373+
nonisolated public func hash(into hasher: inout Hasher) {
374+
\(raw: hashableImplementation)
375+
}
375376
}
376-
return false
377-
}
378-
}
377+
"""
379378

380-
extension MemberTypeSyntax {
381-
var isDictionary: Bool {
382-
if self.baseType.isSwift,
383-
self.name.text == "Dictionary" {
384-
return true
385-
}
386-
return false
379+
return hashableExtensionDecl.as(ExtensionDeclSyntax.self)
387380
}
388381
}
389382

390-
extension TypeSyntax {
391-
var isDictionary: Bool {
392-
if self.is(DictionaryTypeSyntax.self) {
393-
return true
394-
}
395-
if self.as(IdentifierTypeSyntax.self)?.isDictionary ?? false {
396-
return true
397-
}
398-
if self.as(MemberTypeSyntax.self)?.isDictionary ?? false {
399-
return true
400-
}
401-
return false
402-
}
383+
@main
384+
struct EquatablePlugin: CompilerPlugin {
385+
let providingMacros: [Macro.Type] = [
386+
EquatableMacro.self,
387+
EquatableIgnoredMacro.self,
388+
EquatableIgnoredUnsafeClosureMacro.self
389+
]
403390
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import SwiftSyntax
2+
3+
extension IdentifierTypeSyntax {
4+
var isSwift: Bool {
5+
if self.name.text == "Swift" {
6+
return true
7+
}
8+
return false
9+
}
10+
11+
var isArray: Bool {
12+
if self.name.text == "Array" {
13+
return true
14+
}
15+
return false
16+
}
17+
18+
var isDictionary: Bool {
19+
if self.name.text == "Dictionary" {
20+
return true
21+
}
22+
return false
23+
}
24+
}

0 commit comments

Comments
 (0)