Skip to content

Commit 1b6dd2f

Browse files
authored
fix(patch): ignore all properties decorated with SwiftUI property wrappers (#14)
1 parent c279c4b commit 1b6dd2f

File tree

4 files changed

+152
-41
lines changed

4 files changed

+152
-41
lines changed

Sources/EquatableMacros/EquatableIgnoredMacro.swift

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import SwiftSyntaxMacros
2424
/// - Closure properties
2525
/// - Properties already marked with `@Binding`
2626
public struct EquatableIgnoredMacro: PeerMacro {
27+
private static let unignorablePropertyWrappers: Set = [
28+
"Binding",
29+
"FocusedBinding"
30+
]
31+
2732
public static func expansion(
2833
of node: AttributeSyntax,
2934
providingPeersOf declaration: some DeclSyntaxProtocol,
@@ -50,23 +55,21 @@ public struct EquatableIgnoredMacro: PeerMacro {
5055
}
5156
}
5257

53-
// Should not be applied to @Binding
54-
let hasBinding = varDecl.attributes.contains { attribute in
55-
if let attributeName = attribute.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text {
56-
return attributeName == "Binding"
57-
}
58-
return false
59-
}
60-
61-
guard !hasBinding else {
58+
if let unignorableAttribute = varDecl.attributes
59+
.compactMap(attributeName(_:))
60+
.first(where: unignorablePropertyWrappers.contains(_:)) {
6261
let diagnostic = Diagnostic(
6362
node: node,
64-
message: MacroExpansionErrorMessage("@EquatableIgnored cannot be applied to @Binding properties")
63+
message: MacroExpansionErrorMessage("@EquatableIgnored cannot be applied to @\(unignorableAttribute) properties")
6564
)
6665
context.diagnose(diagnostic)
6766
return []
6867
}
6968

7069
return []
7170
}
71+
72+
private static func attributeName(_ attribute: AttributeListSyntax.Element) -> String? {
73+
attribute.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.trimmed.description
74+
}
7275
}

Sources/EquatableMacros/EquatableMacro.swift

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import Foundation
2-
import SwiftCompilerPlugin
32
import SwiftDiagnostics
43
import SwiftSyntax
54
import SwiftSyntaxBuilder
@@ -76,14 +75,28 @@ import SwiftSyntaxMacros
7675
/// ```
7776
public struct EquatableMacro: ExtensionMacro {
7877
private static let skippablePropertyWrappers: Set = [
79-
"State",
80-
"StateObject",
81-
"ObservedObject",
82-
"EnvironmentObject",
78+
"AccessibilityFocusState",
79+
"AppStorage",
80+
"Bindable",
8381
"Environment",
82+
"EnvironmentObject",
83+
"FetchRequest",
8484
"FocusState",
85+
"FocusedObject",
86+
"FocusedValue",
87+
"GestureState",
88+
"NSApplicationDelegateAdaptor",
89+
"Namespace",
90+
"ObservedObject",
91+
"PhysicalMetric",
92+
"ScaledMetric",
8593
"SceneStorage",
86-
"AppStorage"
94+
"SectionedFetchRequest",
95+
"State",
96+
"StateObject",
97+
"UIApplicationDelegateAdaptor",
98+
"WKApplicationDelegateAdaptor",
99+
"WKExtensionDelegateAdaptor"
87100
]
88101

89102
// swiftlint:disable:next cyclomatic_complexity function_body_length
@@ -191,7 +204,9 @@ public struct EquatableMacro: ExtensionMacro {
191204
return [extensionSyntax]
192205
}
193206
}
207+
}
194208

209+
extension EquatableMacro {
195210
// Skip properties with SwiftUI attributes (like @State, @Binding, etc.) or if they are marked with @EqutableIgnored
196211
private static func shouldSkip(_ varDecl: VariableDeclSyntax) -> Bool {
197212
varDecl.attributes.contains { attribute in
@@ -379,12 +394,3 @@ public struct EquatableMacro: ExtensionMacro {
379394
return hashableExtensionDecl.as(ExtensionDeclSyntax.self)
380395
}
381396
}
382-
383-
@main
384-
struct EquatablePlugin: CompilerPlugin {
385-
let providingMacros: [Macro.Type] = [
386-
EquatableMacro.self,
387-
EquatableIgnoredMacro.self,
388-
EquatableIgnoredUnsafeClosureMacro.self
389-
]
390-
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import SwiftCompilerPlugin
2+
import SwiftSyntaxMacros
3+
4+
@main
5+
struct EquatablePlugin: CompilerPlugin {
6+
let providingMacros: [Macro.Type] = [
7+
EquatableMacro.self,
8+
EquatableIgnoredMacro.self,
9+
EquatableIgnoredUnsafeClosureMacro.self
10+
]
11+
}

Tests/EquatableMacroTests.swift

Lines changed: 107 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,28 @@ struct EquatableMacroTests {
8787
"""
8888
@Equatable
8989
struct TitleView: View {
90-
@State var dataModel = TitleDataModel()
90+
@AccessibilityFocusState var accessibilityFocusState: Bool
91+
@AppStorage("title") var appTitle: String = "App Title"
92+
@Bindable var bindable = VM()
9193
@Environment(\\.colorScheme) var colorScheme
92-
@StateObject private var viewModel = TitleViewModel()
93-
@ObservedObject var anotherViewModel = AnotherViewModel()
94+
@EnvironmentObject(VM.self) var environmentObject
95+
@FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults<Quake>
9496
@FocusState var isFocused: Bool
97+
@FocusedObject var focusedObject = FocusModel()
98+
@FocusedValue(\\.focusedValue) var focusedValue
99+
@GestureState private var isDetectingLongPress = false
100+
@NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate
101+
@Namespace var namespace
102+
@ObservedObject var anotherViewModel = AnotherViewModel()
103+
@PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5
104+
@ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10
95105
@SceneStorage("title") var title: String = "Default Title"
96-
@AppStorage("title") var appTitle: String = "App Title"
106+
@SectionedFetchRequest<String, Quake>(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults<String, Quake>
107+
@State var dataModel = TitleDataModel()
108+
@StateObject private var viewModel = TitleViewModel()
109+
@UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate
110+
@WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate
111+
@WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate
97112
static let staticInt: Int = 42
98113
let title: String
99114
@@ -105,13 +120,28 @@ struct EquatableMacroTests {
105120
} expansion: {
106121
"""
107122
struct TitleView: View {
108-
@State var dataModel = TitleDataModel()
123+
@AccessibilityFocusState var accessibilityFocusState: Bool
124+
@AppStorage("title") var appTitle: String = "App Title"
125+
@Bindable var bindable = VM()
109126
@Environment(\\.colorScheme) var colorScheme
110-
@StateObject private var viewModel = TitleViewModel()
111-
@ObservedObject var anotherViewModel = AnotherViewModel()
127+
@EnvironmentObject(VM.self) var environmentObject
128+
@FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults<Quake>
112129
@FocusState var isFocused: Bool
130+
@FocusedObject var focusedObject = FocusModel()
131+
@FocusedValue(\\.focusedValue) var focusedValue
132+
@GestureState private var isDetectingLongPress = false
133+
@NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate
134+
@Namespace var namespace
135+
@ObservedObject var anotherViewModel = AnotherViewModel()
136+
@PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5
137+
@ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10
113138
@SceneStorage("title") var title: String = "Default Title"
114-
@AppStorage("title") var appTitle: String = "App Title"
139+
@SectionedFetchRequest<String, Quake>(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults<String, Quake>
140+
@State var dataModel = TitleDataModel()
141+
@StateObject private var viewModel = TitleViewModel()
142+
@UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate
143+
@WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate
144+
@WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate
115145
static let staticInt: Int = 42
116146
let title: String
117147
@@ -135,13 +165,28 @@ struct EquatableMacroTests {
135165
"""
136166
@Equatable
137167
struct TitleView: View {
138-
@SwiftUI.State var dataModel = TitleDataModel()
168+
@SwiftUI.AccessibilityFocusState var accessibilityFocusState: Bool
169+
@SwiftUI.AppStorage("title") var appTitle: String = "App Title"
170+
@SwiftUI.Bindable var bindable = VM()
139171
@SwiftUI.Environment(\\.colorScheme) var colorScheme
140-
@SwiftUI.StateObject private var viewModel = TitleViewModel()
141-
@SwiftUI.ObservedObject var anotherViewModel = AnotherViewModel()
172+
@SwiftUI.EnvironmentObject(VM.self) var environmentObject
173+
@SwiftUI.FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults<Quake>
142174
@SwiftUI.FocusState var isFocused: Bool
175+
@SwiftUI.FocusedObject var focusedObject = FocusModel()
176+
@SwiftUI.FocusedValue(\\.focusedValue) var focusedValue
177+
@SwiftUI.GestureState private var isDetectingLongPress = false
178+
@SwiftUI.NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate
179+
@SwiftUI.Namespace var namespace
180+
@SwiftUI.ObservedObject var anotherViewModel = AnotherViewModel()
181+
@SwiftUI.PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5
182+
@SwiftUI.ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10
143183
@SwiftUI.SceneStorage("title") var title: String = "Default Title"
144-
@SwiftUI.AppStorage("title") var appTitle: String = "App Title"
184+
@SwiftUI.SectionedFetchRequest<String, Quake>(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults<String, Quake>
185+
@SwiftUI.State var dataModel = TitleDataModel()
186+
@SwiftUI.StateObject private var viewModel = TitleViewModel()
187+
@SwiftUI.UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate
188+
@SwiftUI.WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate
189+
@SwiftUI.WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate
145190
static let staticInt: Int = 42
146191
let title: String
147192
@@ -153,13 +198,28 @@ struct EquatableMacroTests {
153198
} expansion: {
154199
"""
155200
struct TitleView: View {
156-
@SwiftUI.State var dataModel = TitleDataModel()
201+
@SwiftUI.AccessibilityFocusState var accessibilityFocusState: Bool
202+
@SwiftUI.AppStorage("title") var appTitle: String = "App Title"
203+
@SwiftUI.Bindable var bindable = VM()
157204
@SwiftUI.Environment(\\.colorScheme) var colorScheme
158-
@SwiftUI.StateObject private var viewModel = TitleViewModel()
159-
@SwiftUI.ObservedObject var anotherViewModel = AnotherViewModel()
205+
@SwiftUI.EnvironmentObject(VM.self) var environmentObject
206+
@SwiftUI.FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults<Quake>
160207
@SwiftUI.FocusState var isFocused: Bool
208+
@SwiftUI.FocusedObject var focusedObject = FocusModel()
209+
@SwiftUI.FocusedValue(\\.focusedValue) var focusedValue
210+
@SwiftUI.GestureState private var isDetectingLongPress = false
211+
@SwiftUI.NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate
212+
@SwiftUI.Namespace var namespace
213+
@SwiftUI.ObservedObject var anotherViewModel = AnotherViewModel()
214+
@SwiftUI.PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5
215+
@SwiftUI.ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10
161216
@SwiftUI.SceneStorage("title") var title: String = "Default Title"
162-
@SwiftUI.AppStorage("title") var appTitle: String = "App Title"
217+
@SwiftUI.SectionedFetchRequest<String, Quake>(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults<String, Quake>
218+
@SwiftUI.State var dataModel = TitleDataModel()
219+
@SwiftUI.StateObject private var viewModel = TitleViewModel()
220+
@SwiftUI.UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate
221+
@SwiftUI.WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate
222+
@SwiftUI.WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate
163223
static let staticInt: Int = 42
164224
let title: String
165225
@@ -277,6 +337,37 @@ struct EquatableMacroTests {
277337
}
278338
}
279339

340+
@Test
341+
func equatableIgnoredCannotBeAppliedToFocusedBindings() async throws {
342+
assertMacro {
343+
"""
344+
@Equatable
345+
struct CustomView: View {
346+
@EquatableIgnored @FocusedBinding(\\.focusedBinding) var focusedBinding
347+
348+
var body: some View {
349+
Text("CustomView")
350+
}
351+
}
352+
"""
353+
} diagnostics: {
354+
"""
355+
@Equatable
356+
┬─────────
357+
╰─ 🛑 @Equatable requires at least one equatable stored property.
358+
struct CustomView: View {
359+
@EquatableIgnored @FocusedBinding(\\.focusedBinding) var focusedBinding
360+
┬────────────────
361+
╰─ 🛑 @EquatableIgnored cannot be applied to @FocusedBinding properties
362+
363+
var body: some View {
364+
Text("CustomView")
365+
}
366+
}
367+
"""
368+
}
369+
}
370+
280371
@Test
281372
func arbitaryClosuresNotAllowed() async throws {
282373
// There is a bug in assertMacro somewhere and it produces the fixit with

0 commit comments

Comments
 (0)