Add `@ObservableDefault` macro (#189)

Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
This commit is contained in:
Kevin Romero Peces-Barba 2024-11-24 14:23:15 -05:00 committed by GitHub
parent a89f799930
commit ef1b2318fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 630 additions and 1 deletions

15
Package.resolved Normal file
View File

@ -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
}

View File

@ -1,5 +1,6 @@
// swift-tools-version:5.11 // swift-tools-version:5.11
import PackageDescription import PackageDescription
import CompilerPluginSupport
let package = Package( let package = Package(
name: "Defaults", name: "Defaults",
@ -16,8 +17,17 @@ let package = Package(
targets: [ targets: [
"Defaults" "Defaults"
] ]
),
.library(
name: "DefaultsMacros",
targets: [
"DefaultsMacros"
]
) )
], ],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.1")
],
targets: [ targets: [
.target( .target(
name: "Defaults", name: "Defaults",
@ -28,6 +38,18 @@ let package = Package(
// .swiftLanguageMode(.v5) // .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( .testTarget(
name: "DefaultsTests", name: "DefaultsTests",
dependencies: [ dependencies: [
@ -36,6 +58,22 @@ let package = Package(
// swiftSettings: [ // swiftSettings: [
// .swiftLanguageMode(.v5) // .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"
]
) )
] ]
) )

View File

@ -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<Value>(_ key: Defaults.Key<Value>) =
#externalMacro(
module: "DefaultsMacrosDeclarations",
type: "ObservableDefaultMacro"
)

View File

@ -0,0 +1,9 @@
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct DefaultsMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
ObservableDefaultMacro.self
]
}

View File

@ -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`"
}
}
}

View File

@ -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?
}
"""#
}
}

View File

@ -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<String> {
.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)
}
}

View File

@ -181,7 +181,7 @@ Defaults[isUnicorn]
### SwiftUI support ### 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`. 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`. 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` #### `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. There's also a `SwiftUI.Toggle` wrapper that makes it easier to create a toggle based on a `Defaults` key with a `Bool` value.