Skip to content

Commit c9d85b0

Browse files
fix(patch): allow @Equatable on types without stored properties (#18)
1 parent 1b6dd2f commit c9d85b0

File tree

4 files changed

+90
-37
lines changed

4 files changed

+90
-37
lines changed

Sources/EquatableMacros/EquatableMacro.swift

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ public struct EquatableMacro: ExtensionMacro {
132132
}
133133

134134
// Skip static properties
135-
if Self.isStatic(varDecl) {
135+
if varDecl.isStatic {
136136
return nil
137137
}
138138

@@ -172,15 +172,6 @@ public struct EquatableMacro: ExtensionMacro {
172172
return Self.compare(lhs: lhs, rhs: rhs)
173173
}
174174

175-
guard !storedProperties.isEmpty else {
176-
let diagnostic = Diagnostic(
177-
node: node,
178-
message: MacroExpansionErrorMessage("@Equatable requires at least one equatable stored property.")
179-
)
180-
context.diagnose(diagnostic)
181-
return []
182-
}
183-
184175
guard let extensionSyntax = Self.generateEquatableExtensionSyntax(
185176
sortedProperties: sortedProperties,
186177
type: type
@@ -189,7 +180,7 @@ public struct EquatableMacro: ExtensionMacro {
189180
}
190181

191182
// Check if the type conforms to `Hashable`
192-
if Self.isHashable(structDecl) {
183+
if structDecl.isHashable {
193184
// If the type conforms to `Hashable` we need to generate the `Hashable` conformance to match
194185
// the properties used in `Equatable` implementation
195186
guard let hashableExtensionSyntax = Self.generateHashableExtensionSyntax(
@@ -248,19 +239,6 @@ extension EquatableMacro {
248239
return false
249240
}
250241

251-
private static func isStatic(_ varDecl: VariableDeclSyntax) -> Bool {
252-
varDecl.modifiers.contains { modifier in
253-
modifier.name.tokenKind == .keyword(.static)
254-
}
255-
}
256-
257-
private static func isHashable(_ structDecl: StructDeclSyntax) -> Bool {
258-
let existingConformances = structDecl.inheritanceClause?.inheritedTypes
259-
.compactMap { $0.type.as(IdentifierTypeSyntax.self)?.name.text }
260-
?? []
261-
return existingConformances.contains("Hashable")
262-
}
263-
264242
private static func isMarkedWithEquatableIgnoredUnsafeClosure(_ varDecl: VariableDeclSyntax) -> Bool {
265243
varDecl.attributes.contains(where: { attribute in
266244
if let attributeName = attribute.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text {
@@ -357,6 +335,18 @@ extension EquatableMacro {
357335
sortedProperties: [(name: String, type: TypeSyntax?)],
358336
type: TypeSyntaxProtocol
359337
) -> ExtensionDeclSyntax? {
338+
guard !sortedProperties.isEmpty else {
339+
let extensionDecl: DeclSyntax = """
340+
extension \(type): Equatable {
341+
nonisolated public static func == (lhs: \(type), rhs: \(type)) -> Bool {
342+
true
343+
}
344+
}
345+
"""
346+
347+
return extensionDecl.as(ExtensionDeclSyntax.self)
348+
}
349+
360350
let comparisons = sortedProperties.map { property in
361351
"lhs.\(property.name) == rhs.\(property.name)"
362352
}.joined(separator: " && ")
@@ -378,6 +368,16 @@ extension EquatableMacro {
378368
sortedProperties: [(name: String, type: TypeSyntax?)],
379369
type: TypeSyntaxProtocol
380370
) -> ExtensionDeclSyntax? {
371+
guard !sortedProperties.isEmpty else {
372+
let hashableExtensionDecl: DeclSyntax = """
373+
extension \(raw: type) {
374+
nonisolated public func hash(into hasher: inout Hasher) {}
375+
}
376+
"""
377+
378+
return hashableExtensionDecl.as(ExtensionDeclSyntax.self)
379+
}
380+
381381
let hashableImplementation = sortedProperties.map { property in
382382
"hasher.combine(\(property.name))"
383383
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import SwiftSyntax
2+
3+
extension StructDeclSyntax {
4+
var isHashable: Bool {
5+
let existingConformances = self.inheritanceClause?.inheritedTypes
6+
.compactMap { $0.type.as(IdentifierTypeSyntax.self)?.name.text }
7+
?? []
8+
return existingConformances.contains("Hashable")
9+
}
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import SwiftSyntax
2+
3+
extension VariableDeclSyntax {
4+
var isStatic: Bool {
5+
self.modifiers.contains { modifier in
6+
modifier.name.tokenKind == .keyword(.static)
7+
}
8+
}
9+
}

Tests/EquatableMacroTests.swift

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,6 @@ struct EquatableMacroTests {
322322
} diagnostics: {
323323
"""
324324
@Equatable
325-
┬─────────
326-
╰─ 🛑 @Equatable requires at least one equatable stored property.
327325
struct CustomView: View {
328326
@EquatableIgnored @Binding var name: String
329327
┬────────────────
@@ -351,20 +349,18 @@ struct EquatableMacroTests {
351349
}
352350
"""
353351
} diagnostics: {
354-
"""
352+
#"""
355353
@Equatable
356-
┬─────────
357-
╰─ 🛑 @Equatable requires at least one equatable stored property.
358354
struct CustomView: View {
359-
@EquatableIgnored @FocusedBinding(\\.focusedBinding) var focusedBinding
355+
@EquatableIgnored @FocusedBinding(\.focusedBinding) var focusedBinding
360356
┬────────────────
361357
╰─ 🛑 @EquatableIgnored cannot be applied to @FocusedBinding properties
362358
363359
var body: some View {
364360
Text("CustomView")
365361
}
366362
}
367-
"""
363+
"""#
368364
}
369365
}
370366

@@ -482,27 +478,65 @@ struct EquatableMacroTests {
482478
@Equatable
483479
struct NoProperties: View {
484480
@EquatableIgnoredUnsafeClosure let onTap: () -> Void
481+
482+
var body: some View {
483+
Text("")
484+
}
485+
}
486+
"""
487+
} expansion: {
488+
"""
489+
struct NoProperties: View {
490+
let onTap: () -> Void
485491
486492
var body: some View {
487493
Text("")
488494
}
489495
}
496+
497+
extension NoProperties: Equatable {
498+
nonisolated public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool {
499+
true
500+
}
501+
}
490502
"""
491-
} diagnostics: {
503+
}
504+
}
505+
506+
@Test
507+
func noEquatablePropertiesConformingToHashable() async throws {
508+
assertMacro {
492509
"""
493510
@Equatable
494-
┬─────────
495-
╰─ 🛑 @Equatable requires at least one equatable stored property.
496-
struct NoProperties: View {
511+
struct NoProperties: View, Hashable {
497512
@EquatableIgnoredUnsafeClosure let onTap: () -> Void
498-
513+
499514
var body: some View {
500515
Text("")
501516
}
502517
}
503518
"""
504519
} expansion: {
505-
""
520+
"""
521+
struct NoProperties: View, Hashable {
522+
let onTap: () -> Void
523+
524+
var body: some View {
525+
Text("")
526+
}
527+
}
528+
529+
extension NoProperties: Equatable {
530+
nonisolated public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool {
531+
true
532+
}
533+
}
534+
535+
extension NoProperties {
536+
nonisolated public func hash(into hasher: inout Hasher) {
537+
}
538+
}
539+
"""
506540
}
507541
}
508542

0 commit comments

Comments
 (0)