diff --git a/Package.resolved b/Package.resolved index 92b313d..0df8253 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "8d494c4d4e60637e6b86ef423fd928ed494c159a57f086c3cd0b198e79a149c9", + "originHash" : "daca06029ff160ad28f0050dd7c763be9b05c5ff7e63fdc11f8d8f212a33344f", "pins" : [ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", - "version" : "601.0.1" + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } } ], diff --git a/Package.swift b/Package.swift index ef51c47..037f04a 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/swiftlang/swift-syntax.git", "600.0.0"..<"602.0.0") + .package(url: "https://github.com/swiftlang/swift-syntax.git", "602.0.0" ..< "603.0.0") ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. diff --git a/README.md b/README.md index 46e2c16..a9423c2 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,75 @@ extension ProfileView: Equatable { lhs.id == rhs.id && lhs.username == rhs.username } } +``` + + ## Isolation +`Equatable` macro supports generating the conformance with different isolation levels by using the `isolation` parameter. +The parameter accepts three values: `.nonisolated` (default), `.isolated`, and `.main` (requires Swift 6.2 or later). +The chosen isolation level will be applied to the generated conformances for both `Equatable` and `Hashable` (if applicable). + +### Nonisolated (default) +The generated `Equatable` conformance is `nonisolated`, meaning it can be called from any context without isolation guarantees. +```swift +@Equatable(isolation: .nonisolated) (also omitting the parameter uses this mode) +struct Person { + let name: String + let age: Int +} +``` + +expands to: +```swift +extension Person: Equatable { +nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.age == rhs.age +} +} +``` + +### Isolated +The generated `Equatable` conformance is `isolated`, meaning it can only be called from within the actor's context. +```swift +@Equatable(isolation: .isolated) +struct Person { + let name: String + let age: Int +} +``` + +expands to: +```swift +extension Person: Equatable { +public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.age == rhs.age +} +} +``` + +### Main (requires Swift 6.2 or later) +A common case is to have a `@MainActor` isolated type, SwiftUI views being a common example. Previously, the generated `Equatable` conformance had to be `nonisolated` in order to satisfy the protocol requirement. +This would then restrict us to access only nonisolated properties of the type in the generated `Equatable` function — which meant that we had to ignore all `@MainActor` isolated properties in the equality comparison. +Swift 6.2 introduced [isolated conformances](https://docs.swift.org/compiler/documentation/diagnostics/isolated-conformances/) allowing us to generate `Equatable` conformances +which are bound to the `@MainActor`. In this way the generated `Equatable` conformance can access `@MainActor` isolated properties of the type synchronously and the compiler will guarantee that the conformance +will be called only from the `@MainActor` context. + +We can do so by specifying `@Equatable(isolation: .main)`, e.g: +```swift +@Equatable(isolation: .main) +@MainActor +struct Person { + let name: String + let age: Int +} +``` + +expands to: +```swift +extension Person: Equatable { + public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.age == rhs.age + } +} ``` ## Safety Considerations @@ -148,9 +217,8 @@ import Equatable @Equatable struct User: Hashable { -let id: Int - -@EquatableIgnored var name = "" + let id: Int + @EquatableIgnored var name = "" } ``` diff --git a/Sources/Equatable/Equatable.swift b/Sources/Equatable/Equatable.swift index 700702e..3608e14 100644 --- a/Sources/Equatable/Equatable.swift +++ b/Sources/Equatable/Equatable.swift @@ -67,8 +67,91 @@ /// } /// } /// ``` +/// +/// +/// ## Isolation +/// `Equatable` macro supports generating the conformance with different isolation levels by using the `isolation` parameter. +/// The parameter accepts three values: `.nonisolated` (default), `.isolated`, and `.main` (requires Swift 6.2 or later). +/// The chosen isolation level will be applied to the generated conformances for both `Equatable` and `Hashable` (if applicable). +/// +/// ### Nonisolated (default) +/// The generated `Equatable` conformance is `nonisolated`, meaning it can be called from any context without isolation guarantees. +/// ```swift +/// @Equatable(isolation: .nonisolated) (also ommiting the parameter uses this mode) +/// struct Person { +/// let name: String +/// let age: Int +/// } +/// ``` +/// +/// expands to: +/// ```swift +/// extension Person: Equatable { +/// nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { +/// lhs.name == rhs.name && lhs.age == rhs.age +/// } +/// } +/// ``` +/// +/// ### Isolated +/// The generated `Equatable` conformance is `isolated`, meaning it can only be called from within the actor's context. +/// ```swift +/// @Equatable(isolation: .isolated) +/// struct Person { +/// let name: String +/// let age: Int +/// } +/// ``` +/// +/// expands to: +/// ```swift +/// extension Person: Equatable { +/// public static func == (lhs: Person, rhs: Person) -> Bool { +/// lhs.name == rhs.name && lhs.age == rhs.age +/// } +/// } +/// ``` +/// +/// ### Main (requires Swift 6.2 or later) +/// A common case is to have a `@MainActor` isolated type, SwiftUI views being a common example. Previously, the generated `Equatable` conformance had to be `nonisolated` in order to satisfy the protocol requirement. +/// This would then restrict us to access only nonisolated properties of the type in the generated `Equatable` function — which ment that we had to ignore all `@MainActor` isolated properties in the equality comparison. +/// Swift 6.2 introduced [isolated confomances](https://docs.swift.org/compiler/documentation/diagnostics/isolated-conformances/) allowing us to generate `Equatable` confomances +/// which are bound to the `@MainActor`. In this way the generated `Equatable` conformance can access `@MainActor` isolated properties of the type synchonously and the compiler will guarantee that the confomance +/// will be called only from the `@MainActor` context. +/// +/// We can do so by specifying `@Equatable(isolation: .main)`, e.g: +/// ```swift +/// @Equatable(isolation: .main) +/// @MainActor +/// struct Person { +/// let name: String +/// let age: Int +/// } +/// ``` +/// +/// expands to: +/// ```swift +/// extension Person: Equatable { +/// public static func == (lhs: Person, rhs: Person) -> Bool { +/// lhs.name == rhs.name && lhs.age == rhs.age +/// } +/// } +/// ``` +/// @attached(extension, conformances: Equatable, Hashable, names: named(==), named(hash(into:))) -public macro Equatable() = #externalMacro(module: "EquatableMacros", type: "EquatableMacro") +public macro Equatable(isolation: Isolation = .nonisolated) = #externalMacro(module: "EquatableMacros", type: "EquatableMacro") + +/// Isolation level for the generated Equatable functions. +public enum Isolation { + /// The generated `Equatable` conformance is `nonisolated`. + case nonisolated + /// The generated `Equatable` conformance is`isolated`. + case isolated + #if swift(>=6.2) + /// The generated `Equatable` conformance is `@MainActor` isolated. + case main + #endif +} /// A peer macro that marks properties to be ignored in `Equatable` conformance generation. /// diff --git a/Sources/EquatableClient/main.swift b/Sources/EquatableClient/main.swift index 5ebd95b..daee67b 100644 --- a/Sources/EquatableClient/main.swift +++ b/Sources/EquatableClient/main.swift @@ -1,135 +1,148 @@ import Equatable #if canImport(SwiftUI) -import Observation -import SwiftUI + import Observation + import SwiftUI -struct MyView: View { - let int: Int + struct MyView: View { + let int: Int - var body: some View { - Text("MyView with int: \(int)") + var body: some View { + Text("MyView with int: \(int)") + } } -} -@Equatable -struct Test { - let name: String - @EquatableIgnoredUnsafeClosure let closure: (() -> Void)? -} + @Equatable + struct Test { + let name: String + @EquatableIgnoredUnsafeClosure let closure: (() -> Void)? + } -struct CustomType: Equatable { - let name: String - let lastName: String - let id: UUID -} + struct CustomType: Equatable { + let name: String + let lastName: String + let id: UUID + } -class ClassType {} + class ClassType {} -extension ClassType: Equatable { - static func == (lhs: ClassType, rhs: ClassType) -> Bool { - lhs === rhs + extension ClassType: Equatable { + static func == (lhs: ClassType, rhs: ClassType) -> Bool { + lhs === rhs + } } -} - -@Equatable -struct ContentView: View { - @State private var count = 0 - let customType: CustomType - let name: String - let color: Color - let id: String - let hour: Int = 21 - @EquatableIgnored let classType: ClassType - @EquatableIgnoredUnsafeClosure let onTapOptional: (() -> Void)? - @EquatableIgnoredUnsafeClosure let onTap: () -> Void - - var body: some View { - VStack { - Text("Hello!") - .foregroundColor(color) - .onTapGesture { - onTapOptional?() - } + + @Equatable + struct ContentView: View { + @State private var count = 0 + let customType: CustomType + let name: String + let color: Color + let id: String + let hour: Int = 21 + @EquatableIgnored let classType: ClassType + @EquatableIgnoredUnsafeClosure let onTapOptional: (() -> Void)? + @EquatableIgnoredUnsafeClosure let onTap: () -> Void + + var body: some View { + VStack { + Text("Hello!") + .foregroundColor(color) + .onTapGesture { + onTapOptional?() + } + } } } -} - -@Equatable -struct Person { - let name: String - let lastName: String - let random: String - let id: UUID - @EquatableIgnoredUnsafeClosure let closure: (() -> Void)? -} - -struct NestedType: Equatable { - let nestedInt: Int -} - -@Equatable -struct Anon { - let nestedType: NestedType - let array: [Int] - let basicInt: Int - let basicString: String -} - -@available(iOS 17.0, macOS 14.0, watchOS 10, tvOS 17, visionOS 1, *) -@Observable -final class TitleDataModel {} - -@available(iOS 17.0, macOS 14.0, watchOS 10, tvOS 17, visionOS 1, *) -@Equatable -struct TitleView: View { - @State var dataModel = TitleDataModel() - @Environment(\.colorScheme) - var colorScheme - let title: String - - var body: some View { - Text(title) + + @Equatable + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID + @EquatableIgnoredUnsafeClosure let closure: (() -> Void)? } -} -@Equatable -struct BandView: View { - @EquatableIgnoredUnsafeClosure let onTap: () -> Void - let name: String + struct NestedType: Equatable { + let nestedInt: Int + } - var body: some View { - Text(name) - .onTapGesture { - onTap() - } + @Equatable + struct Anon { + let nestedType: NestedType + let array: [Int] + let basicInt: Int + let basicString: String } -} - -final class ProfileViewModel: ObservableObject {} - -@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) -@Equatable -struct ProfileView: View { - var username: String // Will be compared - @State private var isLoading = false // Automatically skipped - @ObservedObject var viewModel: ProfileViewModel // Automatically skipped - @EquatableIgnored var cachedValue: String? // This property will be excluded - @EquatableIgnoredUnsafeClosure var onTap: () -> Void // This closure is safe and will be ignored in comparison - let id: UUID // will be compared first for shortcircuiting equality checks - var body: some View { - VStack { - Text(username) - if isLoading { - ProgressView() + + @available(iOS 17.0, macOS 14.0, watchOS 10, tvOS 17, visionOS 1, *) + @Observable + final class TitleDataModel {} + + @available(iOS 17.0, macOS 14.0, watchOS 10, tvOS 17, visionOS 1, *) + @Equatable + struct TitleView: View { + @State var dataModel = TitleDataModel() + @Environment(\.colorScheme) + var colorScheme + let title: String + + var body: some View { + Text(title) + } + } + + @Equatable + struct BandView: View { + @EquatableIgnoredUnsafeClosure let onTap: () -> Void + let name: String + + var body: some View { + Text(name) + .onTapGesture { + onTap() + } + } + } + + final class ProfileViewModel: ObservableObject {} + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + @Equatable + struct ProfileView: View { + var username: String // Will be compared + @State private var isLoading = false // Automatically skipped + @ObservedObject var viewModel: ProfileViewModel // Automatically skipped + @EquatableIgnored var cachedValue: String? // This property will be excluded + @EquatableIgnoredUnsafeClosure var onTap: () -> Void // This closure is safe and will be ignored in comparison + let id: UUID // will be compared first for shortcircuiting equality checks + var body: some View { + VStack { + Text(username) + if isLoading { + ProgressView() + } } } } -} -@Equatable -struct User: Hashable { - let id: Int - @EquatableIgnored var name = "" -} + @Equatable + struct User: Hashable { + let id: Int + @EquatableIgnored var name = "" + } + + #if swift(>=6.2) + @Equatable(isolation: .main) + @MainActor + struct MainActorView: View { + let integer: Int = 23 + let string: String = "Hello" + + var body: some View { + Text("MainActorView a: \(integer) b: \(string)") + } + } + #endif #endif diff --git a/Sources/EquatableMacros/EquatableIgnoredMacro.swift b/Sources/EquatableMacros/EquatableIgnoredMacro.swift index 93799b9..0391e17 100644 --- a/Sources/EquatableMacros/EquatableIgnoredMacro.swift +++ b/Sources/EquatableMacros/EquatableIgnoredMacro.swift @@ -56,8 +56,8 @@ public struct EquatableIgnoredMacro: PeerMacro { } if let unignorableAttribute = varDecl.attributes - .compactMap(attributeName(_:)) - .first(where: unignorablePropertyWrappers.contains(_:)) { + .compactMap(attributeName(_:)) + .first(where: unignorablePropertyWrappers.contains(_:)) { let diagnostic = Diagnostic( node: node, message: MacroExpansionErrorMessage("@EquatableIgnored cannot be applied to @\(unignorableAttribute) properties") diff --git a/Sources/EquatableMacros/EquatableMacro.swift b/Sources/EquatableMacros/EquatableMacro.swift index 07c057d..ad623ca 100644 --- a/Sources/EquatableMacros/EquatableMacro.swift +++ b/Sources/EquatableMacros/EquatableMacro.swift @@ -7,7 +7,7 @@ import SwiftSyntaxMacros /// A macro that automatically generates an `Equatable` conformance for structs. /// /// This macro creates a standard equality implementation by comparing all stored properties -/// that aren't explicitly marked to be skipped with `@EquatableIgnored`. +/// that aren't explicitly marked to be skipped with `@EquatableIgnored. /// Properties with SwiftUI property wrappers (like `@State`, `@ObservedObject`, etc.) /// /// Structs with arbitary closures are not supported unless they are marked explicitly with `@EquatableIgnoredUnsafeClosure` - @@ -73,8 +73,79 @@ import SwiftSyntaxMacros /// } /// } /// ``` +/// +/// +/// ## Isolation +/// `Equatable` macro supports generating the conformance with different isolation levels by using the `isolation` parameter. +/// The parameter accepts three values: `.nonisolated` (default), `.isolated`, and `.main` (requires Swift 6.2 or later). +/// The chosen isolation level will be applied to the generated conformances for both `Equatable` and `Hashable` (if applicable). +/// +/// ### Nonisolated (default) +/// The generated `Equatable` conformance is `nonisolated`, meaning it can be called from any context without isolation guarantees. +/// ```swift +/// @Equatable(isolation: .nonisolated) (also ommiting the parameter uses this mode) +/// struct Person { +/// let name: String +/// let age: Int +/// } +/// ``` +/// +/// expands to: +/// ```swift +/// extension Person: Equatable { +/// nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { +/// lhs.name == rhs.name && lhs.age == rhs.age +/// } +/// } +/// ``` +/// +/// ### Isolated +/// The generated `Equatable` conformance is `isolated`, meaning it can only be called from within the actor's context. +/// ```swift +/// @Equatable(isolation: .isolated) +/// struct Person { +/// let name: String +/// let age: Int +/// } +/// ``` +/// +/// expands to: +/// ```swift +/// extension Person: Equatable { +/// public static func == (lhs: Person, rhs: Person) -> Bool { +/// lhs.name == rhs.name && lhs.age == rhs.age +/// } +/// } +/// ``` +/// +/// ### Main (requires Swift 6.2 or later) +/// A common case is to have a `@MainActor` isolated type, SwiftUI views being a common example. Previously, the generated `Equatable` conformance had to be `nonisolated` in order to satisfy the protocol requirement. +/// This would then restrict us to access only nonisolated properties of the type in the generated `Equatable` function — which ment that we had to ignore all `@MainActor` isolated properties in the equality comparison. +/// Swift 6.2 introduced [isolated confomances](https://docs.swift.org/compiler/documentation/diagnostics/isolated-conformances/) allowing us to generate `Equatable` confomances +/// which are bound to the `@MainActor`. In this way the generated `Equatable` conformance can access `@MainActor` isolated properties of the type synchonously and the compiler will guarantee that the confomance +/// will be called only from the `@MainActor` context. +/// +/// We can do so by specifying `@Equatable(isolation: .main)`, e.g: +/// ```swift +/// @Equatable(isolation: .main) +/// @MainActor +/// struct Person { +/// let name: String +/// let age: Int +/// } +/// ``` +/// +/// expands to: +/// ```swift +/// extension Person: Equatable { +/// public static func == (lhs: Person, rhs: Person) -> Bool { +/// lhs.name == rhs.name && lhs.age == rhs.age +/// } +/// } +/// ``` +/// public struct EquatableMacro: ExtensionMacro { - private static let skippablePropertyWrappers: Set = [ + static let skippablePropertyWrappers: Set = [ "AccessibilityFocusState", "AppStorage", "Bindable", @@ -107,6 +178,8 @@ public struct EquatableMacro: ExtensionMacro { conformingTo _: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { + // Extract isolation argument from the macro + let isolation = extractIsolation(from: node) ?? .nonisolated // Ensure we're attached to a struct guard let structDecl = declaration.as(StructDeclSyntax.self) else { let diagnostic = Diagnostic( @@ -138,7 +211,7 @@ public struct EquatableMacro: ExtensionMacro { // Check if it's a closure that should trigger diagnostic let isClosureProperty = (binding.typeAnnotation?.type).map(isClosure) == true || - (binding.initializer?.value.is(ClosureExprSyntax.self) ?? false) + (binding.initializer?.value.is(ClosureExprSyntax.self) ?? false) if isClosureProperty { let diagnostic = Self.makeClosureDiagnostic(for: varDecl) @@ -151,12 +224,13 @@ public struct EquatableMacro: ExtensionMacro { // Sort properties: "id" first, then by type complexity let sortedProperties = storedProperties.sorted { lhs, rhs in - return Self.compare(lhs: lhs, rhs: rhs) + Self.compare(lhs: lhs, rhs: rhs) } guard let extensionSyntax = Self.generateEquatableExtensionSyntax( sortedProperties: sortedProperties, - type: type + type: type, + isolation: isolation ) else { return [] } @@ -165,7 +239,8 @@ public struct EquatableMacro: ExtensionMacro { if structDecl.isHashable { guard let hashableExtensionSyntax = Self.generateHashableExtensionSyntax( sortedProperties: sortedProperties, - type: type + type: type, + isolation: isolation ) else { return [extensionSyntax] } @@ -175,200 +250,3 @@ public struct EquatableMacro: ExtensionMacro { } } } - -extension EquatableMacro { - // Skip properties with SwiftUI attributes (like @State, @Binding, etc.) or if they are marked with @EqutableIgnored - private static func shouldSkip(_ varDecl: VariableDeclSyntax) -> Bool { - varDecl.attributes.contains { attribute in - if let atribute = attribute.as(AttributeSyntax.self), - Self.shouldSkip(atribute: atribute) { - return true - } - return false - } - } - - private static func shouldSkip(atribute node: AttributeSyntax) -> Bool { - if let identifierType = node.attributeName.as(IdentifierTypeSyntax.self), - Self.shouldSkip(identifierType: identifierType) { - return true - } - if let memberType = node.attributeName.as(MemberTypeSyntax.self), - Self.shouldSkip(memberType: memberType) { - return true - } - return false - } - - private static func shouldSkip(identifierType node: IdentifierTypeSyntax) -> Bool { - if node.name.text == "EquatableIgnored" { - return true - } - if Self.skippablePropertyWrappers.contains(node.name.text) { - return true - } - return false - } - - private static func shouldSkip(memberType node: MemberTypeSyntax) -> Bool { - if node.baseType.as(IdentifierTypeSyntax.self)?.name.text == "SwiftUI", - Self.skippablePropertyWrappers.contains(node.name.text) { - return true - } - return false - } - - private static func isMarkedWithEquatableIgnoredUnsafeClosure(_ varDecl: VariableDeclSyntax) -> Bool { - varDecl.attributes.contains(where: { attribute in - if let attributeName = attribute.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text { - return attributeName == "EquatableIgnoredUnsafeClosure" - } - return false - }) - } - - private static func compare(lhs: (name: String, type: TypeSyntax?), rhs: (name: String, type: TypeSyntax?)) -> Bool { - // "id" always comes first - if lhs.name == "id" { return true } - if rhs.name == "id" { return false } - - let lhsComplexity = typeComplexity(lhs.type) - let rhsComplexity = typeComplexity(rhs.type) - - if lhsComplexity == rhsComplexity { - return lhs.name < rhs.name - } - return lhsComplexity < rhsComplexity - } - - // swiftlint:disable:next cyclomatic_complexity - private static func typeComplexity(_ type: TypeSyntax?) -> Int { - guard let type else { return 100 } // Unknown types go last - - let typeString = type.description.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) - - switch typeString { - case "Bool": return 1 - case "Int", "Int8", "Int16", "Int32", "Int64": return 2 - case "UInt", "UInt8", "UInt16", "UInt32", "UInt64": return 3 - case "Float", "Double": return 4 - case "String": return 5 - case "Character": return 6 - case "Date": return 7 - case "Data": return 8 - case "URL": return 9 - case "UUID": return 10 - default: - if type.is(OptionalTypeSyntax.self) { - if let wrappedType = type.as(OptionalTypeSyntax.self)?.wrappedType { - return typeComplexity(wrappedType) + 20 - } - } - - if type.isArray { - return 30 - } - - if type.isDictionary { - return 40 - } - - return 50 - } - } - - private static func makeClosureDiagnostic(for varDecl: VariableDeclSyntax) -> Diagnostic { - let attribute = AttributeSyntax( - leadingTrivia: .space, - atSign: .atSignToken(), - attributeName: IdentifierTypeSyntax(name: .identifier("EquatableIgnoredUnsafeClosure")), - trailingTrivia: .space - ) - let existingAttributes = varDecl.attributes - let newAttributes = existingAttributes + [.attribute(attribute.with(\.leadingTrivia, .space))] - let fixedDecl = varDecl.with(\.attributes, newAttributes) - let diagnostic = Diagnostic( - node: varDecl, - message: MacroExpansionErrorMessage("Arbitary closures are not supported in @Equatable"), - fixIt: .replace( - message: SimpleFixItMessage( - message: """ - Consider marking the closure with\ - @EquatableIgnoredUnsafeClosure if it doesn't effect the view's body output. - """, - fixItID: MessageID( - domain: "", - id: "test" - ) - ), - oldNode: varDecl, - newNode: fixedDecl - ) - ) - - return diagnostic - } - - private static func generateEquatableExtensionSyntax( - sortedProperties: [(name: String, type: TypeSyntax?)], - type: TypeSyntaxProtocol - ) -> ExtensionDeclSyntax? { - guard !sortedProperties.isEmpty else { - let extensionDecl: DeclSyntax = """ - extension \(type): Equatable { - nonisolated public static func == (lhs: \(type), rhs: \(type)) -> Bool { - true - } - } - """ - - return extensionDecl.as(ExtensionDeclSyntax.self) - } - - let comparisons = sortedProperties.map { property in - "lhs.\(property.name) == rhs.\(property.name)" - }.joined(separator: " && ") - - let equalityImplementation = comparisons.isEmpty ? "true" : comparisons - - let extensionDecl: DeclSyntax = """ - extension \(type): Equatable { - nonisolated public static func == (lhs: \(type), rhs: \(type)) -> Bool { - \(raw: equalityImplementation) - } - } - """ - - return extensionDecl.as(ExtensionDeclSyntax.self) - } - - private static func generateHashableExtensionSyntax( - sortedProperties: [(name: String, type: TypeSyntax?)], - type: TypeSyntaxProtocol - ) -> ExtensionDeclSyntax? { - guard !sortedProperties.isEmpty else { - let hashableExtensionDecl: DeclSyntax = """ - extension \(raw: type) { - nonisolated public func hash(into hasher: inout Hasher) {} - } - """ - - return hashableExtensionDecl.as(ExtensionDeclSyntax.self) - } - - let hashableImplementation = sortedProperties.map { property in - "hasher.combine(\(property.name))" - } - .joined(separator: "\n") - - let hashableExtensionDecl: DeclSyntax = """ - extension \(raw: type) { - nonisolated public func hash(into hasher: inout Hasher) { - \(raw: hashableImplementation) - } - } - """ - - return hashableExtensionDecl.as(ExtensionDeclSyntax.self) - } -} diff --git a/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Diagnostic.swift b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Diagnostic.swift new file mode 100644 index 0000000..5791acf --- /dev/null +++ b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Diagnostic.swift @@ -0,0 +1,39 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension EquatableMacro { + static func makeClosureDiagnostic(for varDecl: VariableDeclSyntax) -> Diagnostic { + let attribute = AttributeSyntax( + leadingTrivia: .space, + atSign: .atSignToken(), + attributeName: IdentifierTypeSyntax(name: .identifier("EquatableIgnoredUnsafeClosure")), + trailingTrivia: .space + ) + let existingAttributes = varDecl.attributes + let newAttributes = existingAttributes + [.attribute(attribute.with(\.leadingTrivia, .space))] + let fixedDecl = varDecl.with(\.attributes, newAttributes) + let diagnostic = Diagnostic( + node: varDecl, + message: MacroExpansionErrorMessage("Arbitary closures are not supported in @Equatable"), + fixIt: .replace( + message: SimpleFixItMessage( + message: """ + Consider marking the closure with\ + @EquatableIgnoredUnsafeClosure if it doesn't effect the view's body output. + """, + fixItID: MessageID( + domain: "", + id: "test" + ) + ), + oldNode: varDecl, + newNode: fixedDecl + ) + ) + + return diagnostic + } +} diff --git a/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Equatable.swift b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Equatable.swift new file mode 100644 index 0000000..6376360 --- /dev/null +++ b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Equatable.swift @@ -0,0 +1,80 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension EquatableMacro { + // swiftlint:disable:next function_body_length + static func generateEquatableExtensionSyntax( + sortedProperties: [(name: String, type: TypeSyntax?)], + type: TypeSyntaxProtocol, + isolation: Isolation + ) -> ExtensionDeclSyntax? { + guard !sortedProperties.isEmpty else { + let extensionDecl: DeclSyntax = switch isolation { + case .nonisolated: + """ + extension \(type): Equatable { + nonisolated public static func == (lhs: \(type), rhs: \(type)) -> Bool { + true + } + } + """ + case .isolated: + """ + extension \(type): Equatable { + public static func == (lhs: \(type), rhs: \(type)) -> Bool { + true + } + } + """ + case .main: + """ + extension \(type): @MainActor Equatable { + public static func == (lhs: \(type), rhs: \(type)) -> Bool { + true + } + } + """ + } + + return extensionDecl.as(ExtensionDeclSyntax.self) + } + + let comparisons = sortedProperties.map { property in + "lhs.\(property.name) == rhs.\(property.name)" + }.joined(separator: " && ") + + let equalityImplementation = comparisons.isEmpty ? "true" : comparisons + + let extensionDecl: DeclSyntax = switch isolation { + case .nonisolated: + """ + extension \(type): Equatable { + nonisolated public static func == (lhs: \(type), rhs: \(type)) -> Bool { + \(raw: equalityImplementation) + } + } + """ + case .isolated: + """ + extension \(type): Equatable { + public static func == (lhs: \(type), rhs: \(type)) -> Bool { + \(raw: equalityImplementation) + } + } + """ + case .main: + """ + extension \(type): @MainActor Equatable { + public static func == (lhs: \(type), rhs: \(type)) -> Bool { + \(raw: equalityImplementation) + } + } + """ + } + + return extensionDecl.as(ExtensionDeclSyntax.self) + } +} diff --git a/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Hashable.swift b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Hashable.swift new file mode 100644 index 0000000..a5ef35d --- /dev/null +++ b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Hashable.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension EquatableMacro { + // swiftlint:disable:next function_body_length + static func generateHashableExtensionSyntax( + sortedProperties: [(name: String, type: TypeSyntax?)], + type: TypeSyntaxProtocol, + isolation: Isolation + ) -> ExtensionDeclSyntax? { + guard !sortedProperties.isEmpty else { + let hashableExtensionDecl: DeclSyntax = switch isolation { + case .nonisolated: + """ + extension \(raw: type) { + nonisolated public func hash(into hasher: inout Hasher) {} + } + """ + case .isolated: + """ + extension \(raw: type) { + public func hash(into hasher: inout Hasher) {} + } + """ + case .main: + """ + extension \(raw: type) { + public func hash(into hasher: inout Hasher) {} + } + """ + } + + return hashableExtensionDecl.as(ExtensionDeclSyntax.self) + } + + let hashableImplementation = sortedProperties.map { property in + "hasher.combine(\(property.name))" + } + .joined(separator: "\n") + + let hashableExtensionDecl: DeclSyntax = switch isolation { + case .nonisolated: + """ + extension \(raw: type) { + nonisolated public func hash(into hasher: inout Hasher) { + \(raw: hashableImplementation) + } + } + """ + case .isolated: + """ + extension \(raw: type) { + public func hash(into hasher: inout Hasher) { + \(raw: hashableImplementation) + } + } + """ + case .main: + """ + extension \(raw: type) { + public func hash(into hasher: inout Hasher) { + \(raw: hashableImplementation) + } + } + """ + } + + return hashableExtensionDecl.as(ExtensionDeclSyntax.self) + } +} diff --git a/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Helpers.swift b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Helpers.swift new file mode 100644 index 0000000..65c46da --- /dev/null +++ b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Helpers.swift @@ -0,0 +1,71 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension EquatableMacro { + // Skip properties with SwiftUI attributes (like @State, @Binding, etc.) or if they are marked with @EqutableIgnored + static func shouldSkip(_ varDecl: VariableDeclSyntax) -> Bool { + varDecl.attributes.contains { attribute in + if let atribute = attribute.as(AttributeSyntax.self), + shouldSkip(atribute: atribute) { + return true + } + return false + } + } + + static func shouldSkip(atribute node: AttributeSyntax) -> Bool { + if let identifierType = node.attributeName.as(IdentifierTypeSyntax.self), + shouldSkip(identifierType: identifierType) { + return true + } + if let memberType = node.attributeName.as(MemberTypeSyntax.self), + Self.shouldSkip(memberType: memberType) { + return true + } + return false + } + + static func shouldSkip(identifierType node: IdentifierTypeSyntax) -> Bool { + if node.name.text == "EquatableIgnored" { + return true + } + if skippablePropertyWrappers.contains(node.name.text) { + return true + } + return false + } + + static func shouldSkip(memberType node: MemberTypeSyntax) -> Bool { + if node.baseType.as(IdentifierTypeSyntax.self)?.name.text == "SwiftUI", + skippablePropertyWrappers.contains(node.name.text) { + return true + } + return false + } + + static func isMarkedWithEquatableIgnoredUnsafeClosure(_ varDecl: VariableDeclSyntax) -> Bool { + varDecl.attributes.contains(where: { attribute in + if let attributeName = attribute.as(AttributeSyntax.self)?.attributeName.as(IdentifierTypeSyntax.self)?.name.text { + return attributeName == "EquatableIgnoredUnsafeClosure" + } + return false + }) + } + + static func compare(lhs: (name: String, type: TypeSyntax?), rhs: (name: String, type: TypeSyntax?)) -> Bool { + // "id" always comes first + if lhs.name == "id" { return true } + if rhs.name == "id" { return false } + + let lhsComplexity = typeComplexity(lhs.type) + let rhsComplexity = typeComplexity(rhs.type) + + if lhsComplexity == rhsComplexity { + return false + } + return lhsComplexity < rhsComplexity + } +} diff --git a/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Isolation.swift b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Isolation.swift new file mode 100644 index 0000000..a0e737f --- /dev/null +++ b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+Isolation.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension EquatableMacro { + enum Isolation { + case nonisolated + case isolated + case main + } + + static func extractIsolation(from node: AttributeSyntax) -> Isolation? { + guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else { + return nil + } + + for argument in arguments where argument.label?.text == "isolation" { + if let memberAccess = argument.expression.as(MemberAccessExprSyntax.self) { + switch memberAccess.declName.baseName.text { + case "isolated": return .isolated + case "nonisolated": return .nonisolated + #if swift(>=6.2) + case "main": return .main + #endif + default: return nil + } + } + } + + return nil + } +} diff --git a/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+TypeComplexity.swift b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+TypeComplexity.swift new file mode 100644 index 0000000..4f5d989 --- /dev/null +++ b/Sources/EquatableMacros/Extensions/EquatableMacro/EquatableMacro+TypeComplexity.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +extension EquatableMacro { + // swiftlint:disable:next cyclomatic_complexity + static func typeComplexity(_ type: TypeSyntax?) -> Int { + guard let type else { return 100 } // Unknown types go last + + let typeString = type.description.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + + switch typeString { + case "Bool": return 1 + case "Int", "Int8", "Int16", "Int32", "Int64": return 2 + case "UInt", "UInt8", "UInt16", "UInt32", "UInt64": return 3 + case "Float", "Double": return 4 + case "String": return 5 + case "Character": return 6 + case "Date": return 7 + case "Data": return 8 + case "URL": return 9 + case "UUID": return 10 + default: + if type.is(OptionalTypeSyntax.self) { + if let wrappedType = type.as(OptionalTypeSyntax.self)?.wrappedType { + return typeComplexity(wrappedType) + 20 + } + } + + if type.isArray { + return 30 + } + + if type.isDictionary { + return 40 + } + + return 50 + } + } +} diff --git a/Sources/EquatableMacros/Extensions/IdentifierTypeSyntax+Extensions.swift b/Sources/EquatableMacros/Extensions/IdentifierTypeSyntax+Extensions.swift index 54f8d8e..fd814ce 100644 --- a/Sources/EquatableMacros/Extensions/IdentifierTypeSyntax+Extensions.swift +++ b/Sources/EquatableMacros/Extensions/IdentifierTypeSyntax+Extensions.swift @@ -2,21 +2,21 @@ import SwiftSyntax extension IdentifierTypeSyntax { var isSwift: Bool { - if self.name.text == "Swift" { + if name.text == "Swift" { return true } return false } var isArray: Bool { - if self.name.text == "Array" { + if name.text == "Array" { return true } return false } var isDictionary: Bool { - if self.name.text == "Dictionary" { + if name.text == "Dictionary" { return true } return false diff --git a/Sources/EquatableMacros/Extensions/MemberTypeSyntax+Extensions.swift b/Sources/EquatableMacros/Extensions/MemberTypeSyntax+Extensions.swift index d7676ae..20484a9 100644 --- a/Sources/EquatableMacros/Extensions/MemberTypeSyntax+Extensions.swift +++ b/Sources/EquatableMacros/Extensions/MemberTypeSyntax+Extensions.swift @@ -2,16 +2,16 @@ import SwiftSyntax extension MemberTypeSyntax { var isArray: Bool { - if self.baseType.isSwift, - self.name.text == "Array" { + if baseType.isSwift, + name.text == "Array" { return true } return false } var isDictionary: Bool { - if self.baseType.isSwift, - self.name.text == "Dictionary" { + if baseType.isSwift, + name.text == "Dictionary" { return true } return false diff --git a/Sources/EquatableMacros/Extensions/StructDeclSyntax+Extensions.swift b/Sources/EquatableMacros/Extensions/StructDeclSyntax+Extensions.swift index 6d15f61..ae794ec 100644 --- a/Sources/EquatableMacros/Extensions/StructDeclSyntax+Extensions.swift +++ b/Sources/EquatableMacros/Extensions/StructDeclSyntax+Extensions.swift @@ -2,9 +2,9 @@ import SwiftSyntax extension StructDeclSyntax { var isHashable: Bool { - let existingConformances = self.inheritanceClause?.inheritedTypes + let existingConformances = inheritanceClause?.inheritedTypes .compactMap { $0.type.as(IdentifierTypeSyntax.self)?.name.text } - ?? [] + ?? [] return existingConformances.contains("Hashable") } } diff --git a/Sources/EquatableMacros/Extensions/VariableDeclSyntax+Extensions.swift b/Sources/EquatableMacros/Extensions/VariableDeclSyntax+Extensions.swift index eabf9e1..b1c9fb0 100644 --- a/Sources/EquatableMacros/Extensions/VariableDeclSyntax+Extensions.swift +++ b/Sources/EquatableMacros/Extensions/VariableDeclSyntax+Extensions.swift @@ -2,7 +2,7 @@ import SwiftSyntax extension VariableDeclSyntax { var isStatic: Bool { - self.modifiers.contains { modifier in + modifiers.contains { modifier in modifier.name.tokenKind == .keyword(.static) } } diff --git a/Tests/EquatableMacroTests.swift b/Tests/EquatableMacroTests.swift index 1ff0eba..ed0b2fa 100644 --- a/Tests/EquatableMacroTests.swift +++ b/Tests/EquatableMacroTests.swift @@ -5,840 +5,1362 @@ import SwiftSyntaxMacrosGenericTestSupport import Testing extension Issue { - @discardableResult static func record(_ failure: TestFailureSpec) -> Self { - Self.record( - Comment(rawValue: failure.message), - sourceLocation: SourceLocation( - fileID: failure.location.fileID, - filePath: failure.location.filePath, - line: failure.location.line, - column: failure.location.column - ) - ) - } + @discardableResult static func record(_ failure: TestFailureSpec) -> Self { + record( + Comment(rawValue: failure.message), + sourceLocation: SourceLocation( + fileID: failure.location.fileID, + filePath: failure.location.filePath, + line: failure.location.line, + column: failure.location.column + ) + ) + } } let macroSpecs = [ - "Equatable": MacroSpec(type: EquatableMacro.self, conformances: ["Equatable"]), - "EquatableIgnored": MacroSpec(type: EquatableIgnoredMacro.self), - "EquatableIgnoredUnsafeClosure": MacroSpec(type: EquatableIgnoredUnsafeClosureMacro.self), + "Equatable": MacroSpec(type: EquatableMacro.self, conformances: ["Equatable"]), + "EquatableIgnored": MacroSpec(type: EquatableIgnoredMacro.self), + "EquatableIgnoredUnsafeClosure": MacroSpec(type: EquatableIgnoredUnsafeClosureMacro.self), ] func failureHander(_ failure: TestFailureSpec) { - Issue.record(failure) + Issue.record(failure) } @Suite struct EquatableMacroTests { - @Test - func idIsComparedFirst() async throws { - assertMacroExpansion( - """ - @Equatable - struct Person { - let name: String - let lastName: String - let random: String - let id: UUID - } - """, - expandedSource: - """ - struct Person { - let name: String - let lastName: String - let random: String - let id: UUID + enum Isolation { + case nonisolated + case isolated + case main + } + +#if swift(>=6.2) + static let testArguments: [Isolation] = [ + .nonisolated, + .isolated, + .main + ] +#else + static let testArguments: [Isolation] = [ + .nonisolated, + .isolated + ] +#endif + + static func equatableMacro(for isolation: Isolation) -> String { + switch isolation { + case .nonisolated: + return "@Equatable" + case .isolated: + return "@Equatable(isolation: .isolated)" + case .main: + return "@Equatable(isolation: .main)" } - - extension Person: Equatable { - nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { - lhs.id == rhs.id && lhs.lastName == rhs.lastName && lhs.name == rhs.name && lhs.random == rhs.random + } + + @Test(arguments: testArguments) + func idIsComparedFirst(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension Person: Equatable { + nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name && lhs.lastName == rhs.lastName && lhs.random == rhs.random + } } + """ + case .isolated: + """ + extension Person: Equatable { + public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name && lhs.lastName == rhs.lastName && lhs.random == rhs.random + } + } + """ + case .main: + """ + extension Person: @MainActor Equatable { + public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name && lhs.lastName == rhs.lastName && lhs.random == rhs.random + } + } + """ } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + assertMacroExpansion( + """ + \(macro) + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID + } + """, + expandedSource: + """ + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID + } + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } - @Test - func basicTypesComparedBeforeComplex() async throws { - assertMacroExpansion( - """ - struct NestedType: Equatable { - let nestedInt: Int - } - @Equatable - struct A { - let nestedType: NestedType - let array: [Int] - let basicInt: Int - let basicString: String - } - """, - expandedSource: - """ - struct NestedType: Equatable { - let nestedInt: Int - } - struct A { - let nestedType: NestedType - let array: [Int] - let basicInt: Int - let basicString: String - } - - extension A: Equatable { - nonisolated public static func == (lhs: A, rhs: A) -> Bool { - lhs.basicInt == rhs.basicInt && lhs.basicString == rhs.basicString && lhs.array == rhs.array && lhs.nestedType == rhs.nestedType + @Test(arguments: testArguments) + func basicTypesComparedBeforeComplex(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension A: Equatable { + nonisolated public static func == (lhs: A, rhs: A) -> Bool { + lhs.basicInt == rhs.basicInt && lhs.basicString == rhs.basicString && lhs.array == rhs.array && lhs.nestedType == rhs.nestedType + } + } + """ + case .isolated: + """ + extension A: Equatable { + public static func == (lhs: A, rhs: A) -> Bool { + lhs.basicInt == rhs.basicInt && lhs.basicString == rhs.basicString && lhs.array == rhs.array && lhs.nestedType == rhs.nestedType + } + } + """ + case .main: + """ + extension A: @MainActor Equatable { + public static func == (lhs: A, rhs: A) -> Bool { + lhs.basicInt == rhs.basicInt && lhs.basicString == rhs.basicString && lhs.array == rhs.array && lhs.nestedType == rhs.nestedType + } } + """ } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + assertMacroExpansion( + """ + struct NestedType: Equatable { + let nestedInt: Int + } + \(macro) + struct A { + let nestedType: NestedType + let array: [Int] + let basicInt: Int + let basicString: String + } + """, + expandedSource: + """ + struct NestedType: Equatable { + let nestedInt: Int + } + struct A { + let nestedType: NestedType + let array: [Int] + let basicInt: Int + let basicString: String + } + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } - @Test - func swiftUIWrappedPropertiesSkipped() async throws { - assertMacroExpansion( - """ - @Equatable - struct TitleView: View { - @AccessibilityFocusState var accessibilityFocusState: Bool - @AppStorage("title") var appTitle: String = "App Title" - @Bindable var bindable = VM() - @Environment(\\.colorScheme) var colorScheme - @EnvironmentObject(VM.self) var environmentObject - @FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults - @FocusState var isFocused: Bool - @FocusedObject var focusedObject = FocusModel() - @FocusedValue(\\.focusedValue) var focusedValue - @GestureState private var isDetectingLongPress = false - @NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate - @Namespace var namespace - @ObservedObject var anotherViewModel = AnotherViewModel() - @PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5 - @ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10 - @SceneStorage("title") var title: String = "Default Title" - @SectionedFetchRequest(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults - @State var dataModel = TitleDataModel() - @StateObject private var viewModel = TitleViewModel() - @UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate - @WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate - @WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate - static let staticInt: Int = 42 - let title: String - - var body: some View { - Text(title) + @Test(arguments: testArguments) + func swiftUIWrappedPropertiesSkipped(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension TitleView: Equatable { + nonisolated public static func == (lhs: TitleView, rhs: TitleView) -> Bool { + lhs.title == rhs.title + } } - } - """, - expandedSource: - """ - struct TitleView: View { - @AccessibilityFocusState var accessibilityFocusState: Bool - @AppStorage("title") var appTitle: String = "App Title" - @Bindable var bindable = VM() - @Environment(\\.colorScheme) var colorScheme - @EnvironmentObject(VM.self) var environmentObject - @FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults - @FocusState var isFocused: Bool - @FocusedObject var focusedObject = FocusModel() - @FocusedValue(\\.focusedValue) var focusedValue - @GestureState private var isDetectingLongPress = false - @NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate - @Namespace var namespace - @ObservedObject var anotherViewModel = AnotherViewModel() - @PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5 - @ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10 - @SceneStorage("title") var title: String = "Default Title" - @SectionedFetchRequest(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults - @State var dataModel = TitleDataModel() - @StateObject private var viewModel = TitleViewModel() - @UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate - @WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate - @WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate - static let staticInt: Int = 42 - let title: String - - var body: some View { - Text(title) + """ + case .isolated: + """ + extension TitleView: Equatable { + public static func == (lhs: TitleView, rhs: TitleView) -> Bool { + lhs.title == rhs.title + } } + """ + case .main: + """ + extension TitleView: @MainActor Equatable { + public static func == (lhs: TitleView, rhs: TitleView) -> Bool { + lhs.title == rhs.title + } + } + """ } + assertMacroExpansion( + """ + \(macro) + struct TitleView: View { + @AccessibilityFocusState var accessibilityFocusState: Bool + @AppStorage("title") var appTitle: String = "App Title" + @Bindable var bindable = VM() + @Environment(\\.colorScheme) var colorScheme + @EnvironmentObject(VM.self) var environmentObject + @FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults + @FocusState var isFocused: Bool + @FocusedObject var focusedObject = FocusModel() + @FocusedValue(\\.focusedValue) var focusedValue + @GestureState private var isDetectingLongPress = false + @NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate + @Namespace var namespace + @ObservedObject var anotherViewModel = AnotherViewModel() + @PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5 + @ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10 + @SceneStorage("title") var title: String = "Default Title" + @SectionedFetchRequest(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults + @State var dataModel = TitleDataModel() + @StateObject private var viewModel = TitleViewModel() + @UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate + @WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate + @WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate + static let staticInt: Int = 42 + let title: String - extension TitleView: Equatable { - nonisolated public static func == (lhs: TitleView, rhs: TitleView) -> Bool { - lhs.title == rhs.title + var body: some View { + Text(title) + } } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + expandedSource: + """ + struct TitleView: View { + @AccessibilityFocusState var accessibilityFocusState: Bool + @AppStorage("title") var appTitle: String = "App Title" + @Bindable var bindable = VM() + @Environment(\\.colorScheme) var colorScheme + @EnvironmentObject(VM.self) var environmentObject + @FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults + @FocusState var isFocused: Bool + @FocusedObject var focusedObject = FocusModel() + @FocusedValue(\\.focusedValue) var focusedValue + @GestureState private var isDetectingLongPress = false + @NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate + @Namespace var namespace + @ObservedObject var anotherViewModel = AnotherViewModel() + @PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5 + @ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10 + @SceneStorage("title") var title: String = "Default Title" + @SectionedFetchRequest(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults + @State var dataModel = TitleDataModel() + @StateObject private var viewModel = TitleViewModel() + @UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate + @WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate + @WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate + static let staticInt: Int = 42 + let title: String + + var body: some View { + Text(title) + } + } + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } - @Test - func memberSwiftUIWrappedPropertiesSkipped() async throws { - assertMacroExpansion( - """ - @Equatable - struct TitleView: View { - @SwiftUI.AccessibilityFocusState var accessibilityFocusState: Bool - @SwiftUI.AppStorage("title") var appTitle: String = "App Title" - @SwiftUI.Bindable var bindable = VM() - @SwiftUI.Environment(\\.colorScheme) var colorScheme - @SwiftUI.EnvironmentObject(VM.self) var environmentObject - @SwiftUI.FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults - @SwiftUI.FocusState var isFocused: Bool - @SwiftUI.FocusedObject var focusedObject = FocusModel() - @SwiftUI.FocusedValue(\\.focusedValue) var focusedValue - @SwiftUI.GestureState private var isDetectingLongPress = false - @SwiftUI.NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate - @SwiftUI.Namespace var namespace - @SwiftUI.ObservedObject var anotherViewModel = AnotherViewModel() - @SwiftUI.PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5 - @SwiftUI.ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10 - @SwiftUI.SceneStorage("title") var title: String = "Default Title" - @SwiftUI.SectionedFetchRequest(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults - @SwiftUI.State var dataModel = TitleDataModel() - @SwiftUI.StateObject private var viewModel = TitleViewModel() - @SwiftUI.UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate - @SwiftUI.WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate - @SwiftUI.WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate - static let staticInt: Int = 42 - let title: String - - var body: some View { - Text(title) + @Test(arguments: testArguments) + func memberSwiftUIWrappedPropertiesSkipped(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension TitleView: Equatable { + nonisolated public static func == (lhs: TitleView, rhs: TitleView) -> Bool { + lhs.title == rhs.title + } } - } - """, - expandedSource: - """ - struct TitleView: View { - @SwiftUI.AccessibilityFocusState var accessibilityFocusState: Bool - @SwiftUI.AppStorage("title") var appTitle: String = "App Title" - @SwiftUI.Bindable var bindable = VM() - @SwiftUI.Environment(\\.colorScheme) var colorScheme - @SwiftUI.EnvironmentObject(VM.self) var environmentObject - @SwiftUI.FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults - @SwiftUI.FocusState var isFocused: Bool - @SwiftUI.FocusedObject var focusedObject = FocusModel() - @SwiftUI.FocusedValue(\\.focusedValue) var focusedValue - @SwiftUI.GestureState private var isDetectingLongPress = false - @SwiftUI.NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate - @SwiftUI.Namespace var namespace - @SwiftUI.ObservedObject var anotherViewModel = AnotherViewModel() - @SwiftUI.PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5 - @SwiftUI.ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10 - @SwiftUI.SceneStorage("title") var title: String = "Default Title" - @SwiftUI.SectionedFetchRequest(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults - @SwiftUI.State var dataModel = TitleDataModel() - @SwiftUI.StateObject private var viewModel = TitleViewModel() - @SwiftUI.UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate - @SwiftUI.WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate - @SwiftUI.WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate - static let staticInt: Int = 42 - let title: String - - var body: some View { - Text(title) + """ + case .isolated: + """ + extension TitleView: Equatable { + public static func == (lhs: TitleView, rhs: TitleView) -> Bool { + lhs.title == rhs.title + } } + """ + case .main: + """ + extension TitleView: @MainActor Equatable { + public static func == (lhs: TitleView, rhs: TitleView) -> Bool { + lhs.title == rhs.title + } + } + """ } + assertMacroExpansion( + """ + \(macro) + struct TitleView: View { + @SwiftUI.AccessibilityFocusState var accessibilityFocusState: Bool + @SwiftUI.AppStorage("title") var appTitle: String = "App Title" + @SwiftUI.Bindable var bindable = VM() + @SwiftUI.Environment(\\.colorScheme) var colorScheme + @SwiftUI.EnvironmentObject(VM.self) var environmentObject + @SwiftUI.FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults + @SwiftUI.FocusState var isFocused: Bool + @SwiftUI.FocusedObject var focusedObject = FocusModel() + @SwiftUI.FocusedValue(\\.focusedValue) var focusedValue + @SwiftUI.GestureState private var isDetectingLongPress = false + @SwiftUI.NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate + @SwiftUI.Namespace var namespace + @SwiftUI.ObservedObject var anotherViewModel = AnotherViewModel() + @SwiftUI.PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5 + @SwiftUI.ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10 + @SwiftUI.SceneStorage("title") var title: String = "Default Title" + @SwiftUI.SectionedFetchRequest(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults + @SwiftUI.State var dataModel = TitleDataModel() + @SwiftUI.StateObject private var viewModel = TitleViewModel() + @SwiftUI.UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate + @SwiftUI.WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate + @SwiftUI.WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate + static let staticInt: Int = 42 + let title: String - extension TitleView: Equatable { - nonisolated public static func == (lhs: TitleView, rhs: TitleView) -> Bool { - lhs.title == rhs.title + var body: some View { + Text(title) + } } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + expandedSource: + """ + struct TitleView: View { + @SwiftUI.AccessibilityFocusState var accessibilityFocusState: Bool + @SwiftUI.AppStorage("title") var appTitle: String = "App Title" + @SwiftUI.Bindable var bindable = VM() + @SwiftUI.Environment(\\.colorScheme) var colorScheme + @SwiftUI.EnvironmentObject(VM.self) var environmentObject + @SwiftUI.FetchRequest(sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: FetchedResults + @SwiftUI.FocusState var isFocused: Bool + @SwiftUI.FocusedObject var focusedObject = FocusModel() + @SwiftUI.FocusedValue(\\.focusedValue) var focusedValue + @SwiftUI.GestureState private var isDetectingLongPress = false + @SwiftUI.NSApplicationDelegateAdaptor private var appDelegate: MyAppDelegate + @SwiftUI.Namespace var namespace + @SwiftUI.ObservedObject var anotherViewModel = AnotherViewModel() + @SwiftUI.PhysicalMetric(from: .meters) var twoAndAHalfMeters = 2.5 + @SwiftUI.ScaledMetric(relativeTo: .body) var scaledPadding: CGFloat = 10 + @SwiftUI.SceneStorage("title") var title: String = "Default Title" + @SwiftUI.SectionedFetchRequest(sectionIdentifier: \\.day, sortDescriptors: [SortDescriptor(\\.time, order: .reverse)]) var quakes: SectionedFetchResults + @SwiftUI.State var dataModel = TitleDataModel() + @SwiftUI.StateObject private var viewModel = TitleViewModel() + @SwiftUI.UIApplicationDelegateAdaptor private var appDelegate: MyAppDelegate + @SwiftUI.WKApplicationDelegateAdaptor var wkApplicationDelegateAdaptor: MyAppDelegate + @SwiftUI.WKExtensionDelegateAdaptor private var extensionDelegate: MyExtensionDelegate + static let staticInt: Int = 42 + let title: String + + var body: some View { + Text(title) + } + } + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } - @Test - func markedWithEquatableIgnoredSkipped() async throws{ - assertMacroExpansion( - """ - @Equatable - struct BandView: View { - @EquatableIgnored let year: Int - let name: String - - var body: some View { - Text(name) - .onTapGesture { - onTap() - } + @Test(arguments: testArguments) + func markedWithEquatableIgnoredSkipped(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension BandView: Equatable { + nonisolated public static func == (lhs: BandView, rhs: BandView) -> Bool { + lhs.name == rhs.name + } } - } - """, - expandedSource: - """ - struct BandView: View { - let year: Int - let name: String - - var body: some View { - Text(name) - .onTapGesture { - onTap() - } + """ + case .isolated: + """ + extension BandView: Equatable { + public static func == (lhs: BandView, rhs: BandView) -> Bool { + lhs.name == rhs.name + } + } + """ + case .main: + """ + extension BandView: @MainActor Equatable { + public static func == (lhs: BandView, rhs: BandView) -> Bool { + lhs.name == rhs.name + } } + """ } + assertMacroExpansion( + """ + \(macro) + struct BandView: View { + @EquatableIgnored let year: Int + let name: String - extension BandView: Equatable { - nonisolated public static func == (lhs: BandView, rhs: BandView) -> Bool { - lhs.name == rhs.name + var body: some View { + Text(name) + .onTapGesture { + onTap() + } + } } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + expandedSource: + """ + struct BandView: View { + let year: Int + let name: String + + var body: some View { + Text(name) + .onTapGesture { + onTap() + } + } + } + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } @Test func equatableIgnoredCannotBeAppliedToClosures() async throws { - assertMacroExpansion( - """ - struct CustomView: View { - @EquatableIgnored var closure: (() -> Void)? - var name: String + assertMacroExpansion( + """ + struct CustomView: View { + @EquatableIgnored var closure: (() -> Void)? + var name: String - var body: some View { - Text("CustomView") + var body: some View { + Text("CustomView") + } } - } - """, - expandedSource: - """ - struct CustomView: View { - var closure: (() -> Void)? - var name: String + """, + expandedSource: + """ + struct CustomView: View { + var closure: (() -> Void)? + var name: String - var body: some View { - Text("CustomView") + var body: some View { + Text("CustomView") + } } - } - """, - diagnostics: [ - DiagnosticSpec( - message: "@EquatableIgnored cannot be applied to closures", - line: 2, - column: 5 - ) - ], - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + diagnostics: [ + DiagnosticSpec( + message: "@EquatableIgnored cannot be applied to closures", + line: 2, + column: 5 + ) + ], + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } @Test func equatableIgnoredCannotBeAppliedToBindings() async throws { - assertMacroExpansion( - """ - @Equatable - struct CustomView: View { - @EquatableIgnored @Binding var name: String - - var body: some View { - Text("CustomView") + assertMacroExpansion( + """ + @Equatable + struct CustomView: View { + @EquatableIgnored @Binding var name: String + + var body: some View { + Text("CustomView") + } } - } - """, - expandedSource: - """ - struct CustomView: View { - @Binding var name: String - - var body: some View { - Text("CustomView") + """, + expandedSource: + """ + struct CustomView: View { + @Binding var name: String + + var body: some View { + Text("CustomView") + } } - } - - extension CustomView: Equatable { - nonisolated public static func == (lhs: CustomView, rhs: CustomView) -> Bool { - true + + extension CustomView: Equatable { + nonisolated public static func == (lhs: CustomView, rhs: CustomView) -> Bool { + true + } } - } - """, - diagnostics: [ - DiagnosticSpec( - message: "@EquatableIgnored cannot be applied to @Binding properties", - line: 3, - column: 5 - ) - ], - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + diagnostics: [ + DiagnosticSpec( + message: "@EquatableIgnored cannot be applied to @Binding properties", + line: 3, + column: 5 + ) + ], + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } @Test func equatableIgnoredCannotBeAppliedToFocusedBindings() async throws { - assertMacroExpansion( - """ - @Equatable - struct CustomView: View { - @EquatableIgnored @FocusedBinding(\\.focusedBinding) var focusedBinding + assertMacroExpansion( + """ + @Equatable + struct CustomView: View { + @EquatableIgnored @FocusedBinding(\\.focusedBinding) var focusedBinding - var body: some View { - Text("CustomView") + var body: some View { + Text("CustomView") + } } - } - """, - expandedSource: - """ - struct CustomView: View { - @FocusedBinding(\\.focusedBinding) var focusedBinding - - var body: some View { - Text("CustomView") + """, + expandedSource: + """ + struct CustomView: View { + @FocusedBinding(\\.focusedBinding) var focusedBinding + + var body: some View { + Text("CustomView") + } } - } - - extension CustomView: Equatable { - nonisolated public static func == (lhs: CustomView, rhs: CustomView) -> Bool { - true + + extension CustomView: Equatable { + nonisolated public static func == (lhs: CustomView, rhs: CustomView) -> Bool { + true + } } - } - """, - diagnostics: [ - DiagnosticSpec( - message: "@EquatableIgnored cannot be applied to @FocusedBinding properties", - line: 3, - column: 5 - ) - ], - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + diagnostics: [ + DiagnosticSpec( + message: "@EquatableIgnored cannot be applied to @FocusedBinding properties", + line: 3, + column: 5 + ) + ], + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } - @Test - func arbitaryClosuresNotAllowed() async throws { - // There is a bug in assertMacro somewhere and it produces the fixit with - // - // @Equatable - // struct CustomView: View { - // var name: String @EquatableIgnoredUnsafeClosure - // let closure: (() -> Void)? - // - // var body: some View { - // Text("CustomView") - // } - // } - // In reality the fix it works as expected and adds a \n between the @EquatableIgnoredUnsafeClosure and name variable. - assertMacroExpansion( - """ - @Equatable - struct CustomView: View { - var name: String - let closure: (() -> Void)? - - var body: some View { - Text("CustomView") + @Test(arguments: testArguments) + func arbitaryClosuresNotAllowed(isolation: Isolation) async throws { + // There is a bug in assertMacro somewhere and it produces the fixit with + // + // @Equatable + // struct CustomView: View { + // var name: String @EquatableIgnoredUnsafeClosure + // let closure: (() -> Void)? + // + // var body: some View { + // Text("CustomView") + // } + // } + // In reality the fix it works as expected and adds a \n between the @EquatableIgnoredUnsafeClosure and name variable. + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension CustomView: Equatable { + nonisolated public static func == (lhs: CustomView, rhs: CustomView) -> Bool { + lhs.name == rhs.name + } + } + """ + case .isolated: + """ + extension CustomView: Equatable { + public static func == (lhs: CustomView, rhs: CustomView) -> Bool { + lhs.name == rhs.name + } } + """ + case .main: + """ + extension CustomView: @MainActor Equatable { + public static func == (lhs: CustomView, rhs: CustomView) -> Bool { + lhs.name == rhs.name + } + } + """ } - """, - expandedSource: - """ - struct CustomView: View { - var name: String - let closure: (() -> Void)? + assertMacroExpansion( + """ + \(macro) + struct CustomView: View { + var name: String + let closure: (() -> Void)? - var body: some View { - Text("CustomView") + var body: some View { + Text("CustomView") + } } - } + """, + expandedSource: + """ + struct CustomView: View { + var name: String + let closure: (() -> Void)? - extension CustomView: Equatable { - nonisolated public static func == (lhs: CustomView, rhs: CustomView) -> Bool { - lhs.name == rhs.name + var body: some View { + Text("CustomView") + } } - } - """, - diagnostics: [ - DiagnosticSpec( - message: "Arbitary closures are not supported in @Equatable", - line: 4, - column: 5, - fixIts: [ - FixItSpec(message: "Consider marking the closure with@EquatableIgnoredUnsafeClosure if it doesn't effect the view's body output.") - ] - ) - ], - macroSpecs: macroSpecs, - fixedSource: - """ - @Equatable - struct CustomView: View { - var name: String @EquatableIgnoredUnsafeClosure - let closure: (() -> Void)? - - var body: some View { - Text("CustomView") + + \(generatedConformance) + """, + diagnostics: [ + DiagnosticSpec( + message: "Arbitary closures are not supported in @Equatable", + line: 4, + column: 5, + fixIts: [ + FixItSpec(message: "Consider marking the closure with@EquatableIgnoredUnsafeClosure if it doesn't effect the view's body output.") + ] + ) + ], + macroSpecs: macroSpecs, + fixedSource: + """ + \(macro) + struct CustomView: View { + var name: String @EquatableIgnoredUnsafeClosure + let closure: (() -> Void)? + + var body: some View { + Text("CustomView") + } } - } - """, - failureHandler: failureHander - ) + """, + failureHandler: failureHander + ) } - @Test - func closuresMarkedWithEquatableIgnoredUnsafeClosure() async throws { - assertMacroExpansion( - """ - @Equatable - struct CustomView: View { - @EquatableIgnoredUnsafeClosure let closure: (() -> Void)? - var name: String - - var body: some View { - Text("CustomView") + @Test(arguments: testArguments) + func closuresMarkedWithEquatableIgnoredUnsafeClosure(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension CustomView: Equatable { + nonisolated public static func == (lhs: CustomView, rhs: CustomView) -> Bool { + lhs.name == rhs.name + } + } + """ + case .isolated: + """ + extension CustomView: Equatable { + public static func == (lhs: CustomView, rhs: CustomView) -> Bool { + lhs.name == rhs.name + } + } + """ + case .main: + """ + extension CustomView: @MainActor Equatable { + public static func == (lhs: CustomView, rhs: CustomView) -> Bool { + lhs.name == rhs.name + } } + """ } - """, - expandedSource: - """ - struct CustomView: View { - let closure: (() -> Void)? - var name: String + assertMacroExpansion( + """ + \(macro) + struct CustomView: View { + @EquatableIgnoredUnsafeClosure let closure: (() -> Void)? + var name: String - var body: some View { - Text("CustomView") + var body: some View { + Text("CustomView") + } } - } + """, + expandedSource: + """ + struct CustomView: View { + let closure: (() -> Void)? + var name: String - extension CustomView: Equatable { - nonisolated public static func == (lhs: CustomView, rhs: CustomView) -> Bool { - lhs.name == rhs.name + var body: some View { + Text("CustomView") + } } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } - @Test - func noEquatableProperties() async throws { - assertMacroExpansion( - """ - @Equatable - struct NoProperties: View { - @EquatableIgnoredUnsafeClosure let onTap: () -> Void - - var body: some View { - Text("") + @Test(arguments: testArguments) + func noEquatableProperties(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension NoProperties: Equatable { + nonisolated public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool { + true + } + } + """ + case .isolated: + """ + extension NoProperties: Equatable { + public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool { + true + } + } + """ + case .main: + """ + extension NoProperties: @MainActor Equatable { + public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool { + true + } } + """ } - """, - expandedSource: - """ - struct NoProperties: View { - let onTap: () -> Void + assertMacroExpansion( + """ + \(macro) + struct NoProperties: View { + @EquatableIgnoredUnsafeClosure let onTap: () -> Void - var body: some View { - Text("") + var body: some View { + Text("") + } } - } + """, + expandedSource: + """ + struct NoProperties: View { + let onTap: () -> Void - extension NoProperties: Equatable { - nonisolated public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool { - true + var body: some View { + Text("") + } } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } - @Test - func noEquatablePropertiesConformingToHashable() async throws { - assertMacroExpansion( - """ - @Equatable - struct NoProperties: View, Hashable { - @EquatableIgnoredUnsafeClosure let onTap: () -> Void - - var body: some View { - Text("") + @Test(arguments: testArguments) + func noEquatablePropertiesConformingToHashable(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformances = switch isolation { + case .nonisolated: + """ + extension NoProperties: Equatable { + nonisolated public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool { + true + } } - } - """, - expandedSource: - """ - struct NoProperties: View, Hashable { - let onTap: () -> Void - var body: some View { - Text("") + extension NoProperties { + nonisolated public func hash(into hasher: inout Hasher) { + } + } + """ + case .isolated: + """ + extension NoProperties: Equatable { + public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool { + true + } } - } - extension NoProperties: Equatable { - nonisolated public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool { - true + extension NoProperties { + public func hash(into hasher: inout Hasher) { + } + } + """ + case .main: + """ + extension NoProperties: @MainActor Equatable { + public static func == (lhs: NoProperties, rhs: NoProperties) -> Bool { + true + } } - } - extension NoProperties { - nonisolated public func hash(into hasher: inout Hasher) { + extension NoProperties { + public func hash(into hasher: inout Hasher) { + } } + """ } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) - } + assertMacroExpansion( + """ + \(macro) + struct NoProperties: View, Hashable { + @EquatableIgnoredUnsafeClosure let onTap: () -> Void - @Test - func equatableMacro() async throws { - assertMacroExpansion( - """ - struct CustomType: Equatable { - let name: String - let lastName: String - let id: UUID - } + var body: some View { + Text("") + } + } + """, + expandedSource: + """ + struct NoProperties: View, Hashable { + let onTap: () -> Void - class ClassType {} + var body: some View { + Text("") + } + } - extension ClassType: Equatable { - static func == (lhs: ClassType, rhs: ClassType) -> Bool { - lhs === rhs + \(generatedConformances) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) + } + + @Test(arguments: testArguments) + func equatableMacro(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension ContentView: Equatable { + nonisolated public static func == (lhs: ContentView, rhs: ContentView) -> Bool { + lhs.id == rhs.id && lhs.hour == rhs.hour && lhs.name == rhs.name && lhs.customType == rhs.customType && lhs.color == rhs.color + } + } + """ + case .isolated: + """ + extension ContentView: Equatable { + public static func == (lhs: ContentView, rhs: ContentView) -> Bool { + lhs.id == rhs.id && lhs.hour == rhs.hour && lhs.name == rhs.name && lhs.customType == rhs.customType && lhs.color == rhs.color + } + } + """ + case .main: + """ + extension ContentView: @MainActor Equatable { + public static func == (lhs: ContentView, rhs: ContentView) -> Bool { + lhs.id == rhs.id && lhs.hour == rhs.hour && lhs.name == rhs.name && lhs.customType == rhs.customType && lhs.color == rhs.color + } } + """ } + assertMacroExpansion( + """ + struct CustomType: Equatable { + let name: String + let lastName: String + let id: UUID + } - @Equatable - struct ContentView: View { - @State private var count = 0 - let customType: CustomType - let name: String - let color: Color - let id: String - let hour: Int = 21 - @EquatableIgnored let classType: ClassType - @EquatableIgnoredUnsafeClosure let onTapOptional: (() -> Void)? - @EquatableIgnoredUnsafeClosure let onTap: () -> Void - - - var body: some View { - VStack { - Text("Hello!") - .foregroundColor(color) - .onTapGesture { - onTapOptional?() - } + class ClassType {} + + extension ClassType: Equatable { + static func == (lhs: ClassType, rhs: ClassType) -> Bool { + lhs === rhs } } - } - """, - expandedSource: - """ - struct CustomType: Equatable { - let name: String - let lastName: String - let id: UUID - } - class ClassType {} + \(macro) + struct ContentView: View { + @State private var count = 0 + let customType: CustomType + let name: String + let color: Color + let id: String + let hour: Int = 21 + @EquatableIgnored let classType: ClassType + @EquatableIgnoredUnsafeClosure let onTapOptional: (() -> Void)? + @EquatableIgnoredUnsafeClosure let onTap: () -> Void - extension ClassType: Equatable { - static func == (lhs: ClassType, rhs: ClassType) -> Bool { - lhs === rhs + + var body: some View { + VStack { + Text("Hello!") + .foregroundColor(color) + .onTapGesture { + onTapOptional?() + } + } + } } - } - struct ContentView: View { - @State private var count = 0 - let customType: CustomType - let name: String - let color: Color - let id: String - let hour: Int = 21 - let classType: ClassType - let onTapOptional: (() -> Void)? - let onTap: () -> Void - - - var body: some View { - VStack { - Text("Hello!") - .foregroundColor(color) - .onTapGesture { - onTapOptional?() - } + """, + expandedSource: + """ + struct CustomType: Equatable { + let name: String + let lastName: String + let id: UUID + } + + class ClassType {} + + extension ClassType: Equatable { + static func == (lhs: ClassType, rhs: ClassType) -> Bool { + lhs === rhs } } - } + struct ContentView: View { + @State private var count = 0 + let customType: CustomType + let name: String + let color: Color + let id: String + let hour: Int = 21 + let classType: ClassType + let onTapOptional: (() -> Void)? + let onTap: () -> Void + - extension ContentView: Equatable { - nonisolated public static func == (lhs: ContentView, rhs: ContentView) -> Bool { - lhs.id == rhs.id && lhs.hour == rhs.hour && lhs.name == rhs.name && lhs.color == rhs.color && lhs.customType == rhs.customType + var body: some View { + VStack { + Text("Hello!") + .foregroundColor(color) + .onTapGesture { + onTapOptional?() + } + } + } } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } @Test func cannotBeAppliedToNonStruct() async throws { - assertMacroExpansion( - """ - @Equatable - class NotAStruct { - let name: String + assertMacroExpansion( + """ + @Equatable + class NotAStruct { + let name: String + } + """, + expandedSource: + """ + class NotAStruct { + let name: String + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@Equatable can only be applied to structs", + line: 1, + column: 1 + ) + ], + macroSpecs: macroSpecs, + failureHandler: failureHander + ) + } + + @Test(arguments: testArguments) + func arrayProperties(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension Person: Equatable { + nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third && lhs.nestedType == rhs.nestedType + } + } + """ + case .isolated: + """ + extension Person: Equatable { + public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third && lhs.nestedType == rhs.nestedType + } + } + """ + case .main: + """ + extension Person: @MainActor Equatable { + public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third && lhs.nestedType == rhs.nestedType + } + } + """ } - """, - expandedSource: - """ - class NotAStruct { - let name: String + assertMacroExpansion( + """ + \(macro) + struct Person { + struct NestedType: Equatable { + let nestedInt: Int + } + let name: String + let first: [Int] + let second: Array + let third: Swift.Array + let nestedType: NestedType + } + """, + expandedSource: + """ + struct Person { + struct NestedType: Equatable { + let nestedInt: Int + } + let name: String + let first: [Int] + let second: Array + let third: Swift.Array + let nestedType: NestedType + } + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) + } + + @Test(arguments: testArguments) + func dictionaryProperties(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformance = switch isolation { + case .nonisolated: + """ + extension Person: Equatable { + nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third && lhs.nestedType == rhs.nestedType + } + } + """ + case .isolated: + """ + extension Person: Equatable { + public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third && lhs.nestedType == rhs.nestedType + } + } + """ + case .main: + """ + extension Person: @MainActor Equatable { + public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.name == rhs.name && lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third && lhs.nestedType == rhs.nestedType + } + } + """ } - """, - diagnostics: [ - DiagnosticSpec( - message: "@Equatable can only be applied to structs", - line: 1, - column: 1 - ) - ], - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + assertMacroExpansion( + """ + \(macro) + struct Person { + struct NestedType: Equatable { + let nestedInt: Int + } + let name: String + let first: [Int:Int] + let second: Dictionary + let third: Swift.Dictionary + let nestedType: NestedType + } + """, + expandedSource: + """ + struct Person { + struct NestedType: Equatable { + let nestedInt: Int + } + let name: String + let first: [Int:Int] + let second: Dictionary + let third: Swift.Dictionary + let nestedType: NestedType + } + + \(generatedConformance) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } - @Test - func arrayProperties() async throws { - assertMacroExpansion( - """ - @Equatable - struct Person { - struct NestedType: Equatable { - let nestedInt: Int + @Test(arguments: testArguments) + func generateHashableConformanceWhenTypesConformsToHashable(isolation: Isolation) async throws { + let macro = EquatableMacroTests.equatableMacro(for: isolation) + let generatedConformances = switch isolation { + case .nonisolated: + """ + extension User: Equatable { + nonisolated public static func == (lhs: User, rhs: User) -> Bool { + lhs.id == rhs.id && lhs.age == rhs.age && lhs.name == rhs.name + } + } + + extension User { + nonisolated public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(age) + hasher.combine(name) + } + } + """ + case .isolated: + """ + extension User: Equatable { + public static func == (lhs: User, rhs: User) -> Bool { + lhs.id == rhs.id && lhs.age == rhs.age && lhs.name == rhs.name + } } - let name: String - let first: [Int] - let second: Array - let third: Swift.Array - let nestedType: NestedType + + extension User { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(age) + hasher.combine(name) + } + } + """ + case .main: + """ + extension User: @MainActor Equatable { + public static func == (lhs: User, rhs: User) -> Bool { + lhs.id == rhs.id && lhs.age == rhs.age && lhs.name == rhs.name + } + } + + extension User { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(age) + hasher.combine(name) + } + } + """ } - """, - expandedSource: - """ - struct Person { - struct NestedType: Equatable { - let nestedInt: Int + assertMacroExpansion( + """ + \(macro) + struct User: Hashable { + let id: Int + @EquatableIgnored + var name = "" + @EquatableIgnoredUnsafeClosure + var onTap: () -> Void + var age: Int + var name: String + } + """, + expandedSource: + """ + struct User: Hashable { + let id: Int + var name = "" + var onTap: () -> Void + var age: Int + var name: String } - let name: String - let first: [Int] - let second: Array - let third: Swift.Array - let nestedType: NestedType + + \(generatedConformances) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) + } + + #if swift(>=6.2) + @Test + func isolationMainActor() async throws { + assertMacroExpansion( + """ + @Equatable(isolation: .main) + struct MainActorView: View { + let a: Int + let name: String + + var body: some View { + Text("MainActorView") + } + } + """, + expandedSource: + """ + struct MainActorView: View { + let a: Int + let name: String + + var body: some View { + Text("MainActorView") + } + } + + extension MainActorView: @MainActor Equatable { + public static func == (lhs: MainActorView, rhs: MainActorView) -> Bool { + lhs.a == rhs.a && lhs.name == rhs.name + } + } + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } + #endif + @Test + func isolationIsolated() async throws { + assertMacroExpansion( + """ + @Equatable(isolation: .isolated) + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID + } + """, + expandedSource: + """ + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID + } - extension Person: Equatable { - nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { - lhs.name == rhs.name && lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third && lhs.nestedType == rhs.nestedType + extension Person: Equatable { + public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name && lhs.lastName == rhs.lastName && lhs.random == rhs.random + } } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } @Test - func dictionaryProperties() async throws { - assertMacroExpansion( - """ - @Equatable - struct Person { - struct NestedType: Equatable { - let nestedInt: Int + func isolationNonisolated() async throws { + assertMacroExpansion( + """ + @Equatable(isolation: .nonisolated) + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID } - let name: String - let first: [Int:Int] - let second: Dictionary - let third: Swift.Dictionary - let nestedType: NestedType - } - """, - expandedSource: - """ - struct Person { - struct NestedType: Equatable { - let nestedInt: Int + """, + expandedSource: + """ + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID } - let name: String - let first: [Int:Int] - let second: Dictionary - let third: Swift.Dictionary - let nestedType: NestedType - } - extension Person: Equatable { - nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { - lhs.name == rhs.name && lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third && lhs.nestedType == rhs.nestedType + extension Person: Equatable { + nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name && lhs.lastName == rhs.lastName && lhs.random == rhs.random + } } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } @Test - func testGenerateHashableConformanceWhenTypesConformsToHashable() async throws { - assertMacroExpansion( - """ - @Equatable - struct User: Hashable { - let id: Int - @EquatableIgnored - var name = "" - @EquatableIgnoredUnsafeClosure - var onTap: () -> Void - var age: Int - var name: String - } - """, - expandedSource: - """ - struct User: Hashable { - let id: Int - var name = "" - var onTap: () -> Void - var age: Int - var name: String - } + func sameTypeComplexityPreserversOrder() async throws { + assertMacroExpansion( + """ + @Equatable(isolation: .nonisolated) + struct Person { + let first: String + let second: String + let third: String + } + """, + expandedSource: + """ + struct Person { + let first: String + let second: String + let third: String + } - extension User: Equatable { - nonisolated public static func == (lhs: User, rhs: User) -> Bool { - lhs.id == rhs.id && lhs.age == rhs.age && lhs.name == rhs.name + extension Person: Equatable { + nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.first == rhs.first && lhs.second == rhs.second && lhs.third == rhs.third + } } - } + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) + } - extension User { - nonisolated public func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(age) - hasher.combine(name) + // In practice this is not possible because the compiler won't allow it + // but we test it anyway to make sure the macro defaults to nonisolated + // to satisfy code coverage :) + @Test + func nonExistingIsolationCase() async throws { + assertMacroExpansion( + """ + @Equatable(isolation: .nonExistingIsolationCase) + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID } - } - """, - macroSpecs: macroSpecs, - failureHandler: failureHander - ) + """, + expandedSource: + """ + struct Person { + let name: String + let lastName: String + let random: String + let id: UUID + } + + extension Person: Equatable { + nonisolated public static func == (lhs: Person, rhs: Person) -> Bool { + lhs.id == rhs.id && lhs.name == rhs.name && lhs.lastName == rhs.lastName && lhs.random == rhs.random + } + } + """, + macroSpecs: macroSpecs, + failureHandler: failureHander + ) } } + // swiftlint:enable all diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..ebc90f6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,3 @@ +coverage: + ignore: + - "Tests/**"