From ef1b2318fb549002bb533bec3a8ad98ae09f2cb6 Mon Sep 17 00:00:00 2001 From: Kevin Romero Peces-Barba Date: Sun, 24 Nov 2024 14:23:15 -0500 Subject: [PATCH] Add `@ObservableDefault` macro (#189) Co-authored-by: Sindre Sorhus --- Package.resolved | 15 ++ Package.swift | 38 ++++ .../DefaultsMacros/ObservableDefault.swift | 48 +++++ .../DefaultsMacrosPlugin.swift | 9 + .../ObservableDefaultMacro.swift | 191 +++++++++++++++++ .../ObservableDefaultMacroTests.swift | 110 ++++++++++ .../ObservableDefaultTests.swift | 197 ++++++++++++++++++ readme.md | 23 +- 8 files changed, 630 insertions(+), 1 deletion(-) create mode 100644 Package.resolved create mode 100644 Sources/DefaultsMacros/ObservableDefault.swift create mode 100644 Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift create mode 100644 Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift create mode 100644 Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift create mode 100644 Tests/DefaultsMacrosTests/ObservableDefaultTests.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0c5219d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "ab2612a1595aa1a4d9bb3f076279fda1b1b3d17525d1f97e45ce22c697728978", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index cfcb579..faa97db 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ // swift-tools-version:5.11 import PackageDescription +import CompilerPluginSupport let package = Package( name: "Defaults", @@ -16,8 +17,17 @@ let package = Package( targets: [ "Defaults" ] + ), + .library( + name: "DefaultsMacros", + targets: [ + "DefaultsMacros" + ] ) ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.1") + ], targets: [ .target( name: "Defaults", @@ -28,6 +38,18 @@ let package = Package( // .swiftLanguageMode(.v5) // ] ), + .macro( + name: "DefaultsMacrosDeclarations", + dependencies: [ + "Defaults", + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), + .target( + name: "DefaultsMacros", + dependencies: ["Defaults", "DefaultsMacrosDeclarations"] + ), .testTarget( name: "DefaultsTests", dependencies: [ @@ -36,6 +58,22 @@ let package = Package( // swiftSettings: [ // .swiftLanguageMode(.v5) // ] + ), + .testTarget( + name: "DefaultsMacrosDeclarationsTests", + dependencies: [ + "DefaultsMacros", + "DefaultsMacrosDeclarations", + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax") + ] + ), + .testTarget( + name: "DefaultsMacrosTests", + dependencies: [ + "Defaults", + "DefaultsMacros" + ] ) ] ) diff --git a/Sources/DefaultsMacros/ObservableDefault.swift b/Sources/DefaultsMacros/ObservableDefault.swift new file mode 100644 index 0000000..67baed6 --- /dev/null +++ b/Sources/DefaultsMacros/ObservableDefault.swift @@ -0,0 +1,48 @@ +import Defaults +import Foundation + +/** +Attached macro that adds support for using ``Defaults`` in ``@Observable`` classes. + +- Important: To prevent issues with ``@Observable``, you need to also add ``@ObservationIgnored`` to the attached property. + +This macro adds accessor blocks to the attached property similar to those added by `@Observable`. + +For example, given the following source: + +```swift +@Observable +final class CatModel { + @ObservableDefault(.cat) + @ObservationIgnored + var catName: String +} +``` + +The macro will generate the following expansion: + +```swift +@Observable +final class CatModel { + @ObservationIgnored + var catName: String { + get { + access(keypath: \.catName) + return Defaults[.cat] + } + set { + withMutation(keyPath: \catName) { + Defaults[.cat] = newValue + } + } + } +} +``` +*/ +@attached(accessor, names: named(get), named(set)) +@attached(peer, names: prefixed(`_objcAssociatedKey_`)) +public macro ObservableDefault(_ key: Defaults.Key) = + #externalMacro( + module: "DefaultsMacrosDeclarations", + type: "ObservableDefaultMacro" + ) diff --git a/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift b/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift new file mode 100644 index 0000000..689f41d --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift @@ -0,0 +1,9 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct DefaultsMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + ObservableDefaultMacro.self + ] +} diff --git a/Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift b/Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift new file mode 100644 index 0000000..d4b105d --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift @@ -0,0 +1,191 @@ +import SwiftCompilerPlugin +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/** + Macro declaration for the ``ObservableDefault`` macro. +*/ +public struct ObservableDefaultMacro {} + +/** +Conforming to ``AccessorMacro`` allows us to add the property accessors (get/set) that integrate with ``Observable``. +*/ +extension ObservableDefaultMacro: AccessorMacro { + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws(ObservableDefaultMacroError) -> [AccessorDeclSyntax] { + let property = try propertyPattern(of: declaration) + let expression = try keyExpression(of: node) + let associatedKey = associatedKeyToken(for: property) + + // The get/set accessors follow the same pattern that @Observable uses to handle the mutations. + // + // The get accessor also sets up an observation to update the value when the UserDefaults + // changes from elsewhere. Doing so requires attaching it as an Objective-C associated + // object due to limitations with current macro capabilities and Swift concurrency. + return [ + #""" + get { + if objc_getAssociatedObject(self, &Self.\#(associatedKey)) == nil { + let cancellable = Defaults.publisher(\#(expression)) + .sink { [weak self] in + self?.\#(property) = $0.newValue + } + objc_setAssociatedObject(self, &Self.\#(associatedKey), cancellable, .OBJC_ASSOCIATION_RETAIN) + } + access(keyPath: \.\#(property)) + return Defaults[\#(expression)] + } + """#, + #""" + set { + withMutation(keyPath: \.\#(property)) { + Defaults[\#(expression)] = newValue + } + } + """# + ] + } +} + +/** +Conforming to ``PeerMacro`` we can add a new property of type Defaults.Observation that will update the original property whenever +the UserDefaults value changes outside the class. +*/ +extension ObservableDefaultMacro: PeerMacro { + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol, + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.DeclSyntax] { + let property = try propertyPattern(of: declaration) + let associatedKey = associatedKeyToken(for: property) + + return [ + "private nonisolated(unsafe) static var \(associatedKey): Void?" + ] + } +} + +// Logic used by both macro implementations +extension ObservableDefaultMacro { + /** + Extracts the pattern (i.e. the name) of the attached property. + */ + private static func propertyPattern( + of declaration: some SwiftSyntax.DeclSyntaxProtocol + ) throws(ObservableDefaultMacroError) -> TokenSyntax { + // Must be attached to a property declaration. + guard let variableDeclaration = declaration.as(VariableDeclSyntax.self) else { + throw .notAttachedToProperty + } + + // Must be attached to a variable property (i.e. `var` and not `let`). + guard variableDeclaration.bindingSpecifier.tokenKind == .keyword(.var) else { + throw .notAttachedToVariable + } + + // Must be attached to a single property. + guard variableDeclaration.bindings.count == 1, let binding = variableDeclaration.bindings.first else { + throw .notAttachedToSingleProperty + } + + // Must not provide an initializer for the property (i.e. not assign a value). + guard binding.initializer == nil else { + throw .attachedToPropertyWithInitializer + } + + // Must not be attached to property with existing accessor block. + guard binding.accessorBlock == nil else { + throw .attachedToPropertyWithAccessorBlock + } + + // Must use Identifier Pattern. + // See https://swiftinit.org/docs/swift-syntax/swiftsyntax/identifierpatternsyntax + guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else { + throw .attachedToPropertyWithoutIdentifierProperty + } + + return pattern + } + + /** + Extracts the expression used to define the Defaults.Key in the macro call. + */ + private static func keyExpression( + of node: AttributeSyntax + ) throws(ObservableDefaultMacroError) -> ExprSyntax { + // Must receive arguments + guard let arguments = node.arguments else { + throw .calledWithoutArguments + } + + // Must be called with Labeled Expression. + // See https://swiftinit.org/docs/swift-syntax/swiftsyntax/labeledexprlistsyntax + guard let expressionList = arguments.as(LabeledExprListSyntax.self) else { + throw .calledWithoutLabeledExpression + } + + // Must only receive one argument. + guard expressionList.count == 1, let expression = expressionList.first?.expression else { + throw .calledWithMultipleArguments + } + + return expression + } + + /** + Generates the token to use as key for the associated object used to hold the UserDefaults observation. + */ + private static func associatedKeyToken(for property: TokenSyntax) -> TokenSyntax { + "_objcAssociatedKey_\(property)" + } +} + +/** +Error handling for ``ObservableDefaultMacro``. +*/ +public enum ObservableDefaultMacroError: Error { + case notAttachedToProperty + case notAttachedToVariable + case notAttachedToSingleProperty + case attachedToPropertyWithInitializer + case attachedToPropertyWithAccessorBlock + case attachedToPropertyWithoutIdentifierProperty + case calledWithoutArguments + case calledWithoutLabeledExpression + case calledWithMultipleArguments + case calledWithoutFunctionSyntax + case calledWithoutKeyArgument + case calledWithUnsupportedExpression +} + +extension ObservableDefaultMacroError: CustomStringConvertible { + public var description: String { + switch self { + case .notAttachedToProperty: + "@ObservableDefault must be attached to a property." + case .notAttachedToVariable: + "@ObservableDefault must be attached to a `var` property." + case .notAttachedToSingleProperty: + "@ObservableDefault can only be attached to a single property." + case .attachedToPropertyWithInitializer: + "@ObservableDefault must not be attached with a property with a value assigned. To create set default value, provide it in the `Defaults.Key` definition." + case .attachedToPropertyWithAccessorBlock: + "@ObservableDefault must not be attached to a property with accessor block." + case .attachedToPropertyWithoutIdentifierProperty: + "@ObservableDefault could not identify the attached property." + case .calledWithoutArguments, + .calledWithoutLabeledExpression, + .calledWithMultipleArguments, + .calledWithoutFunctionSyntax, + .calledWithoutKeyArgument, + .calledWithUnsupportedExpression: + "@ObservableDefault must be called with (1) argument of type `Defaults.Key`" + } + } +} diff --git a/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift b/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift new file mode 100644 index 0000000..9533b97 --- /dev/null +++ b/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift @@ -0,0 +1,110 @@ +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. +// Cross-compiled tests may still make use of the macro itself in end-to-end tests. +#if canImport(DefaultsMacrosDeclarations) +@testable import DefaultsMacros +@testable import DefaultsMacrosDeclarations + +let testMacros: [String: Macro.Type] = [ + "ObservableDefault": ObservableDefaultMacro.self +] +#else +let testMacros: [String: Macro.Type] = [:] +#endif + +final class ObservableDefaultMacroTests: XCTestCase { + func testExpansionWithMemberSyntax() throws { + #if canImport(DefaultsMacrosDeclarations) + assertMacroExpansion( + declaration(for: "Defaults.Keys.name"), + expandedSource: expectedExpansion(for: "Defaults.Keys.name"), + macros: testMacros, + indentationWidth: .tabs(1) + ) + #else + throw XCTSkip("Macros are only supported when running tests for the host platform") + #endif + } + + func testExpansionWithDotSyntax() throws { + #if canImport(DefaultsMacrosDeclarations) + assertMacroExpansion( + declaration(for: ".name"), + expandedSource: expectedExpansion(for: ".name"), + macros: testMacros, + indentationWidth: .tabs(1) + ) + #else + throw XCTSkip("Macros are only supported when running tests for the host platform") + #endif + } + + func testExpansionWithFunctionCall() throws { + #if canImport(DefaultsMacrosDeclarations) + assertMacroExpansion( + declaration(for: "getName()"), + expandedSource: expectedExpansion(for: "getName()"), + macros: testMacros, + indentationWidth: .tabs(1) + ) + #else + throw XCTSkip("Macros are only supported when running tests for the host platform") + #endif + } + + func testExpansionWithProperty() throws { + #if canImport(DefaultsMacrosDeclarations) + assertMacroExpansion( + declaration(for: "propertyName"), + expandedSource: expectedExpansion(for: "propertyName"), + macros: testMacros, + indentationWidth: .tabs(1) + ) + #else + throw XCTSkip("Macros are only supported when running tests for the host platform") + #endif + } + + private func declaration(for keyExpression: String) -> String { + #""" + @Observable + class ObservableClass { + @ObservableDefault(\#(keyExpression)) + @ObservationIgnored + var name: String + } + """# + } + + private func expectedExpansion(for keyExpression: String) -> String { + #""" + @Observable + class ObservableClass { + @ObservationIgnored + var name: String { + get { + if objc_getAssociatedObject(self, &Self._objcAssociatedKey_name) == nil { + let cancellable = Defaults.publisher(\#(keyExpression)) + .sink { [weak self] in + self?.name = $0.newValue + } + objc_setAssociatedObject(self, &Self._objcAssociatedKey_name, cancellable, .OBJC_ASSOCIATION_RETAIN) + } + access(keyPath: \.name) + return Defaults[\#(keyExpression)] + } + set { + withMutation(keyPath: \.name) { + Defaults[\#(keyExpression)] = newValue + } + } + } + + private nonisolated(unsafe) static var _objcAssociatedKey_name: Void? + } + """# + } +} diff --git a/Tests/DefaultsMacrosTests/ObservableDefaultTests.swift b/Tests/DefaultsMacrosTests/ObservableDefaultTests.swift new file mode 100644 index 0000000..6175105 --- /dev/null +++ b/Tests/DefaultsMacrosTests/ObservableDefaultTests.swift @@ -0,0 +1,197 @@ +import Defaults +import DefaultsMacros +import Foundation +import Observation +import Testing + +private let animalKey = "animalKey" +private let defaultAnimal = "cat" +private let newAnimal = "unicorn" + +private let colorKey = "colorKey" +private let defaultColor = "blue" +private let newColor = "purple" + +extension Defaults.Keys { + static let animal = Defaults.Key(animalKey, default: defaultAnimal) + static let color = Defaults.Key(colorKey, default: defaultColor) +} + +func getKey() -> Defaults.Key { + .animal +} + +let keyProperty = Defaults.Keys.animal + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +private final class TestModelWithDotSyntax: Sendable { + @ObservableDefault(.animal) + @ObservationIgnored + var animal: String +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +private final class TestModelWithFunctionCall { + @ObservableDefault(getKey()) + @ObservationIgnored + var animal: String +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +final class TestModelWithProperty { + @ObservableDefault(keyProperty) + @ObservationIgnored + var animal: String +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +private final class TestModelWithMemberSyntax { + @ObservableDefault(Defaults.Keys.animal) + @ObservationIgnored + var animal: String +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +private final class TestModelWithMultipleValues { + @ObservableDefault(.animal) + @ObservationIgnored + var animal: String + + @ObservableDefault(.color) + @ObservationIgnored + var color: String +} + +@Suite(.serialized) +final class ObservableDefaultTests { + init() { + Defaults.removeAll() + Defaults[.animal] = defaultAnimal + Defaults[.color] = defaultColor + } + + deinit { + Defaults.removeAll() + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) + @Test + func testMacroWithMemberSyntax() async { + let model = TestModelWithMemberSyntax() + #expect(model.animal == defaultAnimal) + + let userDefaultsValue = UserDefaults.standard.string(forKey: animalKey) + #expect(userDefaultsValue == defaultAnimal) + + await confirmation { confirmation in + _ = withObservationTracking { + model.animal + } onChange: { + confirmation() + } + + UserDefaults.standard.set(newAnimal, forKey: animalKey) + } + + #expect(model.animal == newAnimal) + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) + @Test + func testMacroWithDotSyntax() async { + let model = TestModelWithDotSyntax() + #expect(model.animal == defaultAnimal) + + let userDefaultsValue = UserDefaults.standard.string(forKey: animalKey) + #expect(userDefaultsValue == defaultAnimal) + + await confirmation { confirmation in + _ = withObservationTracking { + model.animal + } onChange: { + confirmation() + } + + UserDefaults.standard.set(newAnimal, forKey: animalKey) + } + + #expect(model.animal == newAnimal) + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) + @Test + func testMacroWithFunctionCall() async { + let model = TestModelWithFunctionCall() + #expect(model.animal == defaultAnimal) + + let userDefaultsValue = UserDefaults.standard.string(forKey: animalKey) + #expect(userDefaultsValue == defaultAnimal) + + await confirmation { confirmation in + _ = withObservationTracking { + model.animal + } onChange: { + confirmation() + } + + UserDefaults.standard.set(newAnimal, forKey: animalKey) + } + + #expect(model.animal == newAnimal) + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) + @Test + func testMacroWithProperty() async { + let model = TestModelWithProperty() + #expect(model.animal == defaultAnimal) + + let userDefaultsValue = UserDefaults.standard.string(forKey: animalKey) + #expect(userDefaultsValue == defaultAnimal) + + await confirmation { confirmation in + _ = withObservationTracking { + model.animal + } onChange: { + confirmation() + } + + UserDefaults.standard.set(newAnimal, forKey: animalKey) + } + + #expect(model.animal == newAnimal) + } + + @available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) + @Test + func testMacroWithMultipleValues() async { + let model = TestModelWithMultipleValues() + #expect(model.animal == defaultAnimal) + #expect(model.color == defaultColor) + + await confirmation(expectedCount: 2) { confirmation in + _ = withObservationTracking { + model.animal + } onChange: { + confirmation() + } + + _ = withObservationTracking { + model.color + } onChange: { + confirmation() + } + + UserDefaults.standard.set(newAnimal, forKey: animalKey) + UserDefaults.standard.set(newColor, forKey: colorKey) + } + + #expect(model.animal == newAnimal) + #expect(model.color == newColor) + } +} diff --git a/readme.md b/readme.md index 3dafd90..03022eb 100644 --- a/readme.md +++ b/readme.md @@ -181,7 +181,7 @@ Defaults[isUnicorn] ### SwiftUI support -#### `@Default` +#### `@Default` in `View` You can use the `@Default` property wrapper to get/set a `Defaults` item and also have the view be updated when the value changes. This is similar to `@State`. @@ -207,6 +207,27 @@ Note that it's `@Default`, not `@Defaults`. You cannot use `@Default` in an `ObservableObject`. It's meant to be used in a `View`. +#### `@ObservableDefault` in `@Observable` + +With the `@ObservableDefault` macro, you can use `Defaults` inside `@Observable` classes that use the [Observation](https://developer.apple.com/documentation/observation) framework. Doing so is as simple as importing `DefaultsMacros` and adding two lines to a property (note that adding `@ObservationIgnored` is needed to prevent clashes with `@Observable`): + +> [!IMPORTANT] +> Build times will increase when using macros. +> +> Swift macros depend on the [`swift-syntax`](https://github.com/swiftlang/swift-syntax) package. This means that when you compile code that includes macros as dependencies, you also have to compile `swift-syntax`. It is widely known that doing so has serious impact in build time and, while it is an issue that is being tracked (see [`swift-syntax`#2421](https://github.com/swiftlang/swift-syntax/issues/2421)), there's currently no solution implemented. + +```swift +import Defaults +import DefaultsMacros + +@Observable +final class UnicornManager { + @ObservableDefault(.hasUnicorn) + @ObservationIgnored + var hasUnicorn: Bool +} +``` + #### `Toggle` There's also a `SwiftUI.Toggle` wrapper that makes it easier to create a toggle based on a `Defaults` key with a `Bool` value.