Skip to content

Commit ef1b231

Browse files
Add @ObservableDefault macro (#189)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent a89f799 commit ef1b231

File tree

8 files changed

+630
-1
lines changed

8 files changed

+630
-1
lines changed

Package.resolved

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"originHash" : "ab2612a1595aa1a4d9bb3f076279fda1b1b3d17525d1f97e45ce22c697728978",
3+
"pins" : [
4+
{
5+
"identity" : "swift-syntax",
6+
"kind" : "remoteSourceControl",
7+
"location" : "https://github.com/swiftlang/swift-syntax",
8+
"state" : {
9+
"revision" : "0687f71944021d616d34d922343dcef086855920",
10+
"version" : "600.0.1"
11+
}
12+
}
13+
],
14+
"version" : 3
15+
}

Package.swift

+38
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// swift-tools-version:5.11
22
import PackageDescription
3+
import CompilerPluginSupport
34

45
let package = Package(
56
name: "Defaults",
@@ -16,8 +17,17 @@ let package = Package(
1617
targets: [
1718
"Defaults"
1819
]
20+
),
21+
.library(
22+
name: "DefaultsMacros",
23+
targets: [
24+
"DefaultsMacros"
25+
]
1926
)
2027
],
28+
dependencies: [
29+
.package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.1")
30+
],
2131
targets: [
2232
.target(
2333
name: "Defaults",
@@ -28,6 +38,18 @@ let package = Package(
2838
// .swiftLanguageMode(.v5)
2939
// ]
3040
),
41+
.macro(
42+
name: "DefaultsMacrosDeclarations",
43+
dependencies: [
44+
"Defaults",
45+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
46+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
47+
]
48+
),
49+
.target(
50+
name: "DefaultsMacros",
51+
dependencies: ["Defaults", "DefaultsMacrosDeclarations"]
52+
),
3153
.testTarget(
3254
name: "DefaultsTests",
3355
dependencies: [
@@ -36,6 +58,22 @@ let package = Package(
3658
// swiftSettings: [
3759
// .swiftLanguageMode(.v5)
3860
// ]
61+
),
62+
.testTarget(
63+
name: "DefaultsMacrosDeclarationsTests",
64+
dependencies: [
65+
"DefaultsMacros",
66+
"DefaultsMacrosDeclarations",
67+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
68+
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
69+
]
70+
),
71+
.testTarget(
72+
name: "DefaultsMacrosTests",
73+
dependencies: [
74+
"Defaults",
75+
"DefaultsMacros"
76+
]
3977
)
4078
]
4179
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Defaults
2+
import Foundation
3+
4+
/**
5+
Attached macro that adds support for using ``Defaults`` in ``@Observable`` classes.
6+
7+
- Important: To prevent issues with ``@Observable``, you need to also add ``@ObservationIgnored`` to the attached property.
8+
9+
This macro adds accessor blocks to the attached property similar to those added by `@Observable`.
10+
11+
For example, given the following source:
12+
13+
```swift
14+
@Observable
15+
final class CatModel {
16+
@ObservableDefault(.cat)
17+
@ObservationIgnored
18+
var catName: String
19+
}
20+
```
21+
22+
The macro will generate the following expansion:
23+
24+
```swift
25+
@Observable
26+
final class CatModel {
27+
@ObservationIgnored
28+
var catName: String {
29+
get {
30+
access(keypath: \.catName)
31+
return Defaults[.cat]
32+
}
33+
set {
34+
withMutation(keyPath: \catName) {
35+
Defaults[.cat] = newValue
36+
}
37+
}
38+
}
39+
}
40+
```
41+
*/
42+
@attached(accessor, names: named(get), named(set))
43+
@attached(peer, names: prefixed(`_objcAssociatedKey_`))
44+
public macro ObservableDefault<Value>(_ key: Defaults.Key<Value>) =
45+
#externalMacro(
46+
module: "DefaultsMacrosDeclarations",
47+
type: "ObservableDefaultMacro"
48+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import SwiftCompilerPlugin
2+
import SwiftSyntaxMacros
3+
4+
@main
5+
struct DefaultsMacrosPlugin: CompilerPlugin {
6+
let providingMacros: [Macro.Type] = [
7+
ObservableDefaultMacro.self
8+
]
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import SwiftCompilerPlugin
2+
import SwiftDiagnostics
3+
import SwiftSyntax
4+
import SwiftSyntaxBuilder
5+
import SwiftSyntaxMacros
6+
7+
/**
8+
Macro declaration for the ``ObservableDefault`` macro.
9+
*/
10+
public struct ObservableDefaultMacro {}
11+
12+
/**
13+
Conforming to ``AccessorMacro`` allows us to add the property accessors (get/set) that integrate with ``Observable``.
14+
*/
15+
extension ObservableDefaultMacro: AccessorMacro {
16+
public static func expansion(
17+
of node: AttributeSyntax,
18+
providingAccessorsOf declaration: some DeclSyntaxProtocol,
19+
in context: some MacroExpansionContext
20+
) throws(ObservableDefaultMacroError) -> [AccessorDeclSyntax] {
21+
let property = try propertyPattern(of: declaration)
22+
let expression = try keyExpression(of: node)
23+
let associatedKey = associatedKeyToken(for: property)
24+
25+
// The get/set accessors follow the same pattern that @Observable uses to handle the mutations.
26+
//
27+
// The get accessor also sets up an observation to update the value when the UserDefaults
28+
// changes from elsewhere. Doing so requires attaching it as an Objective-C associated
29+
// object due to limitations with current macro capabilities and Swift concurrency.
30+
return [
31+
#"""
32+
get {
33+
if objc_getAssociatedObject(self, &Self.\#(associatedKey)) == nil {
34+
let cancellable = Defaults.publisher(\#(expression))
35+
.sink { [weak self] in
36+
self?.\#(property) = $0.newValue
37+
}
38+
objc_setAssociatedObject(self, &Self.\#(associatedKey), cancellable, .OBJC_ASSOCIATION_RETAIN)
39+
}
40+
access(keyPath: \.\#(property))
41+
return Defaults[\#(expression)]
42+
}
43+
"""#,
44+
#"""
45+
set {
46+
withMutation(keyPath: \.\#(property)) {
47+
Defaults[\#(expression)] = newValue
48+
}
49+
}
50+
"""#
51+
]
52+
}
53+
}
54+
55+
/**
56+
Conforming to ``PeerMacro`` we can add a new property of type Defaults.Observation that will update the original property whenever
57+
the UserDefaults value changes outside the class.
58+
*/
59+
extension ObservableDefaultMacro: PeerMacro {
60+
public static func expansion(
61+
of node: SwiftSyntax.AttributeSyntax,
62+
providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol,
63+
in context: some SwiftSyntaxMacros.MacroExpansionContext
64+
) throws -> [SwiftSyntax.DeclSyntax] {
65+
let property = try propertyPattern(of: declaration)
66+
let associatedKey = associatedKeyToken(for: property)
67+
68+
return [
69+
"private nonisolated(unsafe) static var \(associatedKey): Void?"
70+
]
71+
}
72+
}
73+
74+
// Logic used by both macro implementations
75+
extension ObservableDefaultMacro {
76+
/**
77+
Extracts the pattern (i.e. the name) of the attached property.
78+
*/
79+
private static func propertyPattern(
80+
of declaration: some SwiftSyntax.DeclSyntaxProtocol
81+
) throws(ObservableDefaultMacroError) -> TokenSyntax {
82+
// Must be attached to a property declaration.
83+
guard let variableDeclaration = declaration.as(VariableDeclSyntax.self) else {
84+
throw .notAttachedToProperty
85+
}
86+
87+
// Must be attached to a variable property (i.e. `var` and not `let`).
88+
guard variableDeclaration.bindingSpecifier.tokenKind == .keyword(.var) else {
89+
throw .notAttachedToVariable
90+
}
91+
92+
// Must be attached to a single property.
93+
guard variableDeclaration.bindings.count == 1, let binding = variableDeclaration.bindings.first else {
94+
throw .notAttachedToSingleProperty
95+
}
96+
97+
// Must not provide an initializer for the property (i.e. not assign a value).
98+
guard binding.initializer == nil else {
99+
throw .attachedToPropertyWithInitializer
100+
}
101+
102+
// Must not be attached to property with existing accessor block.
103+
guard binding.accessorBlock == nil else {
104+
throw .attachedToPropertyWithAccessorBlock
105+
}
106+
107+
// Must use Identifier Pattern.
108+
// See https://swiftinit.org/docs/swift-syntax/swiftsyntax/identifierpatternsyntax
109+
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else {
110+
throw .attachedToPropertyWithoutIdentifierProperty
111+
}
112+
113+
return pattern
114+
}
115+
116+
/**
117+
Extracts the expression used to define the Defaults.Key in the macro call.
118+
*/
119+
private static func keyExpression(
120+
of node: AttributeSyntax
121+
) throws(ObservableDefaultMacroError) -> ExprSyntax {
122+
// Must receive arguments
123+
guard let arguments = node.arguments else {
124+
throw .calledWithoutArguments
125+
}
126+
127+
// Must be called with Labeled Expression.
128+
// See https://swiftinit.org/docs/swift-syntax/swiftsyntax/labeledexprlistsyntax
129+
guard let expressionList = arguments.as(LabeledExprListSyntax.self) else {
130+
throw .calledWithoutLabeledExpression
131+
}
132+
133+
// Must only receive one argument.
134+
guard expressionList.count == 1, let expression = expressionList.first?.expression else {
135+
throw .calledWithMultipleArguments
136+
}
137+
138+
return expression
139+
}
140+
141+
/**
142+
Generates the token to use as key for the associated object used to hold the UserDefaults observation.
143+
*/
144+
private static func associatedKeyToken(for property: TokenSyntax) -> TokenSyntax {
145+
"_objcAssociatedKey_\(property)"
146+
}
147+
}
148+
149+
/**
150+
Error handling for ``ObservableDefaultMacro``.
151+
*/
152+
public enum ObservableDefaultMacroError: Error {
153+
case notAttachedToProperty
154+
case notAttachedToVariable
155+
case notAttachedToSingleProperty
156+
case attachedToPropertyWithInitializer
157+
case attachedToPropertyWithAccessorBlock
158+
case attachedToPropertyWithoutIdentifierProperty
159+
case calledWithoutArguments
160+
case calledWithoutLabeledExpression
161+
case calledWithMultipleArguments
162+
case calledWithoutFunctionSyntax
163+
case calledWithoutKeyArgument
164+
case calledWithUnsupportedExpression
165+
}
166+
167+
extension ObservableDefaultMacroError: CustomStringConvertible {
168+
public var description: String {
169+
switch self {
170+
case .notAttachedToProperty:
171+
"@ObservableDefault must be attached to a property."
172+
case .notAttachedToVariable:
173+
"@ObservableDefault must be attached to a `var` property."
174+
case .notAttachedToSingleProperty:
175+
"@ObservableDefault can only be attached to a single property."
176+
case .attachedToPropertyWithInitializer:
177+
"@ObservableDefault must not be attached with a property with a value assigned. To create set default value, provide it in the `Defaults.Key` definition."
178+
case .attachedToPropertyWithAccessorBlock:
179+
"@ObservableDefault must not be attached to a property with accessor block."
180+
case .attachedToPropertyWithoutIdentifierProperty:
181+
"@ObservableDefault could not identify the attached property."
182+
case .calledWithoutArguments,
183+
.calledWithoutLabeledExpression,
184+
.calledWithMultipleArguments,
185+
.calledWithoutFunctionSyntax,
186+
.calledWithoutKeyArgument,
187+
.calledWithUnsupportedExpression:
188+
"@ObservableDefault must be called with (1) argument of type `Defaults.Key`"
189+
}
190+
}
191+
}

0 commit comments

Comments
 (0)