├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
├── Sources
├── ObservableUserDefault
│ └── ObservableUserDefault.swift
├── ObservableUserDefaultClient
│ └── main.swift
└── ObservableUserDefaultMacros
│ └── ObservableUserDefaultMacro.swift
└── Tests
└── ObservableUserDefaultTests
└── ObservableUserDefaultTests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 David Steppenbeck
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-syntax",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-syntax.git",
7 | "state" : {
8 | "revision" : "303e5c5c36d6a558407d364878df131c3546fad8",
9 | "version" : "510.0.2"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.10
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 | import CompilerPluginSupport
6 |
7 | let package = Package(
8 | name: "ObservableUserDefault",
9 | platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13), .visionOS(.v1)],
10 | products: [
11 | // Products define the executables and libraries a package produces, making them visible to other packages.
12 | .library(
13 | name: "ObservableUserDefault",
14 | targets: ["ObservableUserDefault"]
15 | ),
16 | .executable(
17 | name: "ObservableUserDefaultClient",
18 | targets: ["ObservableUserDefaultClient"]
19 | ),
20 | ],
21 | dependencies: [
22 | // Depend on the latest Swift 5.10 release of SwiftSyntax
23 | .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.0"),
24 | ],
25 | targets: [
26 | // Targets are the basic building blocks of a package, defining a module or a test suite.
27 | // Targets can depend on other targets in this package and products from dependencies.
28 | // Macro implementation that performs the source transformation of a macro.
29 | .macro(
30 | name: "ObservableUserDefaultMacros",
31 | dependencies: [
32 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
33 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
34 | ]
35 | ),
36 |
37 | // Library that exposes a macro as part of its API, which is used in client programs.
38 | .target(name: "ObservableUserDefault", dependencies: ["ObservableUserDefaultMacros"]),
39 |
40 | // A client of the library, which is able to use the macro in its own code.
41 | .executableTarget(name: "ObservableUserDefaultClient", dependencies: ["ObservableUserDefault"]),
42 |
43 | // A test target used to develop the macro implementation.
44 | .testTarget(
45 | name: "ObservableUserDefaultTests",
46 | dependencies: [
47 | "ObservableUserDefaultMacros",
48 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
49 | ]
50 | ),
51 | ]
52 | )
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ObservableUserDefault
2 |
3 | `ObservableUserDefault` is an attached Swift macro for properties in `@Observable` classes that provides accessor blocks with getters and setters that read and write the properties in `UserDefaults`.
4 |
5 | ## Usage
6 |
7 | Note that the `@ObservationIgnored` annotation is necessary when using `@ObservableUserDefault` owing to the way that `@Observable` works: Without it, the `@Observable` macro will inject its own getters and setters in the accessor block and the macros will conflict, causing errors.
8 |
9 | ```swift
10 | import ObservableUserDefault
11 |
12 | @Observable
13 | final class StorageModel {
14 | @ObservableUserDefault
15 | @ObservationIgnored
16 | var name: String
17 | }
18 | ```
19 |
20 | This will automatically generate the following code:
21 |
22 | ```swift
23 | @Observable
24 | final class StorageModel {
25 | @ObservationIgnored
26 | var name: String {
27 | get {
28 | access(keyPath: \.name)
29 | return UserDefaults.name
30 | }
31 | set {
32 | withMutation(keyPath: \.name) {
33 | UserDefaults.name = newValue
34 | }
35 | }
36 | }
37 | }
38 | ```
39 |
40 | Arguments can be provided to the macro for cases when you want to provide `UserDefaults` storage keys, default values, and suites explicitly.
41 | When attached to non-optional types, default values must be included in the arguments.
42 |
43 | ```swift
44 | import ObservableUserDefault
45 |
46 | @Observable
47 | final class StorageModel {
48 | @ObservableUserDefault(.init(key: "NAME_STORAGE_KEY", defaultValue: "John Appleseed", store: .standard))
49 | @ObservationIgnored
50 | var name: String
51 | }
52 | ```
53 |
54 | This will automatically generate the following code:
55 |
56 | ```swift
57 | @Observable
58 | final class StorageModel {
59 | @ObservationIgnored
60 | var name: String {
61 | get {
62 | access(keyPath: \.name)
63 | return UserDefaults.standard.value(forKey: "NAME_STORAGE_KEY") as? String ?? "John Appleseed"
64 | }
65 | set {
66 | withMutation(keyPath: \.name) {
67 | UserDefaults.standard.set(newValue, forKey: "NAME_STORAGE_KEY")
68 | }
69 | }
70 | }
71 | }
72 | ```
73 |
74 | When attached to optional types, omit the default value from the argument.
75 |
76 | ```swift
77 | import ObservableUserDefault
78 |
79 | @Observable
80 | final class StorageModel {
81 | @ObservableUserDefault(.init(key: "NAME_STORAGE_KEY", store: .standard))
82 | @ObservationIgnored
83 | var name: String?
84 | }
85 | ```
86 |
87 | This will automatically generate the following code:
88 |
89 | ```swift
90 | @Observable
91 | final class StorageModel {
92 | @ObservationIgnored
93 | var name: String? {
94 | get {
95 | access(keyPath: \.name)
96 | return UserDefaults.standard.value(forKey: "NAME_STORAGE_KEY") as? String
97 | }
98 | set {
99 | withMutation(keyPath: \.name) {
100 | UserDefaults.standard.set(newValue, forKey: "NAME_STORAGE_KEY")
101 | }
102 | }
103 | }
104 | }
105 | ```
106 |
107 | ## Installation
108 |
109 | The package can be installed using [Swift Package Manager](https://swift.org/package-manager/). To add ObservableUserDefault to your Xcode project, select *File > Add Package Dependancies...* and search for the repository URL: `https://github.com/davidsteppenbeck/ObservableUserDefault.git`.
110 |
111 | ## License
112 |
113 | ObservableUserDefault is available under the MIT license. See the LICENSE file for more info.
114 |
--------------------------------------------------------------------------------
/Sources/ObservableUserDefault/ObservableUserDefault.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// An attached macro for properties in `@Observable` classes that provides accessor blocks with getters and setters that read and write the properties in `UserDefaults`.
4 | /// Applying the macro to anything other than a `var` without an accessor block will result in a compile time error.
5 | ///
6 | /// The macro can be used with or without an explicit argument.
7 | ///
8 | /// When an explicit argument is provided, the macro will use the storage key, default value, and `UserDefaults` store to generate the expanded macro code,
9 | /// otherwise the macro will provide a static property on `UserDefaults` with the same name as the variable that the macro is attached to.
10 | ///
11 | /// For example, applying `@ObservableUserDefault` to an `@ObservationIgnored` property inside an `@Observable` class
12 | ///
13 | /// @Observable
14 | /// final class StorageModel {
15 | /// @ObservableUserDefault
16 | /// @ObservationIgnored
17 | /// var name: String
18 | /// }
19 | ///
20 | /// results in the following code automatically
21 | ///
22 | /// @Observable
23 | /// final class StorageModel {
24 | /// @ObservationIgnored
25 | /// var name: String {
26 | /// get {
27 | /// access(keyPath: \.name)
28 | /// return UserDefaults.name
29 | /// }
30 | /// set {
31 | /// withMutation(keyPath: \.name) {
32 | /// UserDefaults.name = newValue
33 | /// }
34 | /// }
35 | /// }
36 | /// }
37 | ///
38 | /// Explicit arguments can be provided to the macro if you want to use specific keys, default values, and `UserDefaults` suites.
39 | /// When the macro is attached to non-optional types, the arguments must include default values, for example
40 | ///
41 | /// @Observable
42 | /// final class StorageModel {
43 | /// @ObservableUserDefault(.init(key: "NAME_STORAGE_KEY", defaultValue: "John Appleseed", store: .standard))
44 | /// @ObservationIgnored
45 | /// var name: String
46 | /// }
47 | ///
48 | /// results in the following code automatically
49 | ///
50 | /// @Observable
51 | /// final class StorageModel {
52 | /// @ObservationIgnored
53 | /// var name: String {
54 | /// get {
55 | /// access(keyPath: \.name)
56 | /// return UserDefaults.standard.value(forKey: "NAME_STORAGE_KEY") as? String ?? "John Appleseed"
57 | /// }
58 | /// set {
59 | /// withMutation(keyPath: \.name) {
60 | /// UserDefaults.standard.set(newValue, forKey: "NAME_STORAGE_KEY")
61 | /// }
62 | /// }
63 | /// }
64 | /// }
65 | ///
66 | /// When the macro is attached to optional types, default values should be omitted from arguments, for example
67 | ///
68 | /// @Observable
69 | /// final class StorageModel {
70 | /// @ObservableUserDefault(.init(key: "DATE_STORAGE_KEY", store: .standard))
71 | /// @ObservationIgnored
72 | /// var date: Date?
73 | /// }
74 | ///
75 | /// results in the following code automatically
76 | ///
77 | /// @Observable
78 | /// final class StorageModel {
79 | /// @ObservationIgnored
80 | /// var date: Date? {
81 | /// get {
82 | /// access(keyPath: \.date)
83 | /// return UserDefaults.standard.value(forKey: "DATE_STORAGE_KEY") as? Date
84 | /// }
85 | /// set {
86 | /// withMutation(keyPath: \.date) {
87 | /// UserDefaults.standard.set(newValue, forKey: "DATE_STORAGE_KEY")
88 | /// }
89 | /// }
90 | /// }
91 | /// }
92 | ///
93 | /// Note that the `@ObservationIgnored` annotation is necessary when using `@ObservableUserDefault` owing to the way that `@Observable` works:
94 | /// Without it, the `@Observable` macro will inject its own getters and setters in the accessor block and the macros will conflict, causing errors.
95 | @attached(accessor, names: named(get), named(set))
96 | public macro ObservableUserDefault() = #externalMacro(
97 | module: "ObservableUserDefaultMacros",
98 | type: "ObservableUserDefaultMacro"
99 | )
100 |
101 | /// An attached macro for properties in `@Observable` classes that provides accessor blocks with getters and setters that read and write the properties in `UserDefaults`.
102 | /// Applying the macro to anything other than a `var` without an accessor block will result in a compile time error.
103 | ///
104 | /// The macro can be used with or without an explicit argument.
105 | ///
106 | /// When an explicit argument is provided, the macro will use the storage key, default value, and `UserDefaults` store to generate the expanded macro code,
107 | /// otherwise the macro will provide a static property on `UserDefaults` with the same name as the variable that the macro is attached to.
108 | ///
109 | /// For example, applying `@ObservableUserDefault` to an `@ObservationIgnored` property inside an `@Observable` class
110 | ///
111 | /// @Observable
112 | /// final class StorageModel {
113 | /// @ObservableUserDefault
114 | /// @ObservationIgnored
115 | /// var name: String
116 | /// }
117 | ///
118 | /// results in the following code automatically
119 | ///
120 | /// @Observable
121 | /// final class StorageModel {
122 | /// @ObservationIgnored
123 | /// var name: String {
124 | /// get {
125 | /// access(keyPath: \.name)
126 | /// return UserDefaults.name
127 | /// }
128 | /// set {
129 | /// withMutation(keyPath: \.name) {
130 | /// UserDefaults.name = newValue
131 | /// }
132 | /// }
133 | /// }
134 | /// }
135 | ///
136 | /// Explicit arguments can be provided to the macro if you want to use specific keys, default values, and `UserDefaults` suites.
137 | /// When the macro is attached to non-optional types, the arguments must include default values, for example
138 | ///
139 | /// @Observable
140 | /// final class StorageModel {
141 | /// @ObservableUserDefault(.init(key: "NAME_STORAGE_KEY", defaultValue: "John Appleseed", store: .standard))
142 | /// @ObservationIgnored
143 | /// var name: String
144 | /// }
145 | ///
146 | /// results in the following code automatically
147 | ///
148 | /// @Observable
149 | /// final class StorageModel {
150 | /// @ObservationIgnored
151 | /// var name: String {
152 | /// get {
153 | /// access(keyPath: \.name)
154 | /// return UserDefaults.standard.value(forKey: "NAME_STORAGE_KEY") as? String ?? "John Appleseed"
155 | /// }
156 | /// set {
157 | /// withMutation(keyPath: \.name) {
158 | /// UserDefaults.standard.set(newValue, forKey: "NAME_STORAGE_KEY")
159 | /// }
160 | /// }
161 | /// }
162 | /// }
163 | ///
164 | /// When the macro is attached to optional types, default values should be omitted from arguments, for example
165 | ///
166 | /// @Observable
167 | /// final class StorageModel {
168 | /// @ObservableUserDefault(.init(key: "DATE_STORAGE_KEY", store: .standard))
169 | /// @ObservationIgnored
170 | /// var date: Date?
171 | /// }
172 | ///
173 | /// results in the following code automatically
174 | ///
175 | /// @Observable
176 | /// final class StorageModel {
177 | /// @ObservationIgnored
178 | /// var date: Date? {
179 | /// get {
180 | /// access(keyPath: \.date)
181 | /// return UserDefaults.standard.value(forKey: "DATE_STORAGE_KEY") as? Date
182 | /// }
183 | /// set {
184 | /// withMutation(keyPath: \.date) {
185 | /// UserDefaults.standard.set(newValue, forKey: "DATE_STORAGE_KEY")
186 | /// }
187 | /// }
188 | /// }
189 | /// }
190 | ///
191 | /// Note that the `@ObservationIgnored` annotation is necessary when using `@ObservableUserDefault` owing to the way that `@Observable` works:
192 | /// Without it, the `@Observable` macro will inject its own getters and setters in the accessor block and the macros will conflict, causing errors.
193 | @attached(accessor, names: named(get), named(set))
194 | public macro ObservableUserDefault(_ metadata: ObservableUserDefaultMetadata) = #externalMacro(
195 | module: "ObservableUserDefaultMacros",
196 | type: "ObservableUserDefaultMacro"
197 | )
198 |
199 | /// Use as an argument to the `ObservableUserDefault` macro when attached to non-optional types that require a default value.
200 | public struct ObservableUserDefaultMetadata {
201 | let key: String
202 | let defaultValue: DefaultValue
203 | let store: UserDefaults
204 |
205 | public init(key: String, defaultValue: DefaultValue, store: UserDefaults) {
206 | self.key = key
207 | self.defaultValue = defaultValue
208 | self.store = store
209 | }
210 | }
211 |
212 | /// An attached macro for properties in `@Observable` classes that provides accessor blocks with getters and setters that read and write the properties in `UserDefaults`.
213 | /// Applying the macro to anything other than a `var` without an accessor block will result in a compile time error.
214 | ///
215 | /// The macro can be used with or without an explicit argument.
216 | ///
217 | /// When an explicit argument is provided, the macro will use the storage key, default value, and `UserDefaults` store to generate the expanded macro code,
218 | /// otherwise the macro will provide a static property on `UserDefaults` with the same name as the variable that the macro is attached to.
219 | ///
220 | /// For example, applying `@ObservableUserDefault` to an `@ObservationIgnored` property inside an `@Observable` class
221 | ///
222 | /// @Observable
223 | /// final class StorageModel {
224 | /// @ObservableUserDefault
225 | /// @ObservationIgnored
226 | /// var name: String
227 | /// }
228 | ///
229 | /// results in the following code automatically
230 | ///
231 | /// @Observable
232 | /// final class StorageModel {
233 | /// @ObservationIgnored
234 | /// var name: String {
235 | /// get {
236 | /// access(keyPath: \.name)
237 | /// return UserDefaults.name
238 | /// }
239 | /// set {
240 | /// withMutation(keyPath: \.name) {
241 | /// UserDefaults.name = newValue
242 | /// }
243 | /// }
244 | /// }
245 | /// }
246 | ///
247 | /// Explicit arguments can be provided to the macro if you want to use specific keys, default values, and `UserDefaults` suites.
248 | /// When the macro is attached to non-optional types, the arguments must include default values, for example
249 | ///
250 | /// @Observable
251 | /// final class StorageModel {
252 | /// @ObservableUserDefault(.init(key: "NAME_STORAGE_KEY", defaultValue: "John Appleseed", store: .standard))
253 | /// @ObservationIgnored
254 | /// var name: String
255 | /// }
256 | ///
257 | /// results in the following code automatically
258 | ///
259 | /// @Observable
260 | /// final class StorageModel {
261 | /// @ObservationIgnored
262 | /// var name: String {
263 | /// get {
264 | /// access(keyPath: \.name)
265 | /// return UserDefaults.standard.value(forKey: "NAME_STORAGE_KEY") as? String ?? "John Appleseed"
266 | /// }
267 | /// set {
268 | /// withMutation(keyPath: \.name) {
269 | /// UserDefaults.standard.set(newValue, forKey: "NAME_STORAGE_KEY")
270 | /// }
271 | /// }
272 | /// }
273 | /// }
274 | ///
275 | /// When the macro is attached to optional types, default values should be omitted from arguments, for example
276 | ///
277 | /// @Observable
278 | /// final class StorageModel {
279 | /// @ObservableUserDefault(.init(key: "DATE_STORAGE_KEY", store: .standard))
280 | /// @ObservationIgnored
281 | /// var date: Date?
282 | /// }
283 | ///
284 | /// results in the following code automatically
285 | ///
286 | /// @Observable
287 | /// final class StorageModel {
288 | /// @ObservationIgnored
289 | /// var date: Date? {
290 | /// get {
291 | /// access(keyPath: \.date)
292 | /// return UserDefaults.standard.value(forKey: "DATE_STORAGE_KEY") as? Date
293 | /// }
294 | /// set {
295 | /// withMutation(keyPath: \.date) {
296 | /// UserDefaults.standard.set(newValue, forKey: "DATE_STORAGE_KEY")
297 | /// }
298 | /// }
299 | /// }
300 | /// }
301 | ///
302 | /// Note that the `@ObservationIgnored` annotation is necessary when using `@ObservableUserDefault` owing to the way that `@Observable` works:
303 | /// Without it, the `@Observable` macro will inject its own getters and setters in the accessor block and the macros will conflict, causing errors.
304 | @attached(accessor, names: named(get), named(set))
305 | public macro ObservableUserDefault(_ metadata: ObservableOptionalUserDefaultMetadata) = #externalMacro(
306 | module: "ObservableUserDefaultMacros",
307 | type: "ObservableUserDefaultMacro"
308 | )
309 |
310 | /// Use as an argument to the `ObservableUserDefault` macro when attached to optional types that do not require a default value.
311 | public struct ObservableOptionalUserDefaultMetadata {
312 | let key: String
313 | let store: UserDefaults
314 |
315 | public init(key: String, store: UserDefaults) {
316 | self.key = key
317 | self.store = store
318 | }
319 | }
320 |
--------------------------------------------------------------------------------
/Sources/ObservableUserDefaultClient/main.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import ObservableUserDefault
3 |
4 | @available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, macCatalyst 17.0, visionOS 1.0, *)
5 | @Observable final class Person {
6 |
7 | // Use without arguments to access static properties, for example, `UserDefaults.name`.
8 | @ObservableUserDefault
9 | @ObservationIgnored
10 | var name: String
11 |
12 | // Use arguments containing default values when attached to non-optional types.
13 | @ObservableUserDefault(.init(key: "ADDRESS_STORAGE_KEY_EXAMPLE", defaultValue: "One Infinite Loop", store: .standard))
14 | @ObservationIgnored
15 | var address: String
16 |
17 | // Use custom `UserDefaults` suites by specifying the store name.
18 | @ObservableUserDefault(.init(key: "NUMBER_STORAGE_KEY_EXAMPLE", defaultValue: Int.zero, store: .shared))
19 | @ObservationIgnored
20 | var number: Int
21 |
22 | // Use arguments containing no default values when attached to optional types.
23 | @ObservableUserDefault(.init(key: "DATE_STORAGE_KEY_EXAMPLE", store: .standard))
24 | @ObservationIgnored
25 | var date: Date?
26 |
27 | // Use arguments containing default values when attached to non-optional arrays.
28 | @ObservableUserDefault(.init(key: "NAMES_STORAGE_KEY_EXAMPLE", defaultValue: ["Tim", "Craig"], store: .shared))
29 | @ObservationIgnored
30 | var names: [String]
31 |
32 | // Use arguments containing no default values when attached to optional arrays.
33 | @ObservableUserDefault(.init(key: "DATES_STORAGE_KEY_EXAMPLE", store: .standard))
34 | @ObservationIgnored
35 | var dates: [Date]?
36 |
37 | @ObservableUserDefault(.init(key: "DICT_STORAGE_KEY_EXAMPLE", defaultValue: ["Tim": 0941], store: .shared))
38 | @ObservationIgnored
39 | var dict: [String: Int]
40 |
41 | }
42 |
43 | fileprivate extension UserDefaults {
44 | static let shared = UserDefaults(suiteName: "SHARED_SUITE_EXAMPLE")!
45 | static var name: String = "John Appleseed"
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/ObservableUserDefaultMacros/ObservableUserDefaultMacro.swift:
--------------------------------------------------------------------------------
1 | import SwiftCompilerPlugin
2 | import SwiftSyntax
3 | import SwiftSyntaxBuilder
4 | import SwiftSyntaxMacros
5 |
6 | public struct ObservableUserDefaultMacro: AccessorMacro {
7 |
8 | public static func expansion(
9 | of node: AttributeSyntax,
10 | providingAccessorsOf declaration: some DeclSyntaxProtocol,
11 | in context: some MacroExpansionContext
12 | ) throws -> [AccessorDeclSyntax] {
13 | // Ensure the macro can only be attached to variable properties.
14 | guard let varDecl = declaration.as(VariableDeclSyntax.self), varDecl.bindingSpecifier.tokenKind == .keyword(.var) else {
15 | throw ObservableUserDefaultError.notVariableProperty
16 | }
17 |
18 | // Ensure the variable is defines a single property declaration, for example,
19 | // `var name: String` and not multiple declarations such as `var name, address: String`.
20 | guard varDecl.bindings.count == 1, let binding = varDecl.bindings.first else {
21 | throw ObservableUserDefaultError.propertyMustContainOnlyOneBinding
22 | }
23 |
24 | // Ensure there is no computed property block attached to the variable already.
25 | guard binding.accessorBlock == nil else {
26 | throw ObservableUserDefaultError.propertyMustHaveNoAccessorBlock
27 | }
28 |
29 | // Ensure there is no initial value assigned to the variable.
30 | guard binding.initializer == nil else {
31 | throw ObservableUserDefaultError.propertyMustHaveNoInitializer
32 | }
33 |
34 | // For simple variable declarations, the binding pattern is `IdentifierPatternSyntax`,
35 | // which defines the name of a single variable.
36 | guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else {
37 | throw ObservableUserDefaultError.propertyMustUseSimplePatternSyntax
38 | }
39 |
40 | // Check if there is an explicit argument provided to the macro.
41 | // If so, extract the key, defaultValue, and store to use, and provide stored properties from `UserDefaults` that use the values extracted from the macro argument.
42 | // If not, use a static property on `UserDefaults` with the same name as the property.
43 | guard let arguments = node.arguments else {
44 | return [
45 | #"""
46 | get {
47 | access(keyPath: \.\#(pattern))
48 | return UserDefaults.\#(pattern)
49 | }
50 | """#,
51 | #"""
52 | set {
53 | withMutation(keyPath: \.\#(pattern)) {
54 | UserDefaults.\#(pattern) = newValue
55 | }
56 | }
57 | """#
58 | ]
59 | }
60 |
61 | // Ensure the macro has one and only one argument.
62 | guard let exprList = arguments.as(LabeledExprListSyntax.self), exprList.count == 1, let expr = exprList.first?.expression.as(FunctionCallExprSyntax.self) else {
63 | throw ObservableUserDefaultArgumentError.macroShouldOnlyContainOneArgument
64 | }
65 |
66 | func keyExpr() -> ExprSyntax? {
67 | expr.arguments.first(where: { $0.label?.text == "key" })?.expression
68 | }
69 |
70 | func defaultValueExpr() -> ExprSyntax? {
71 | expr.arguments.first(where: { $0.label?.text == "defaultValue" })?.expression
72 | }
73 |
74 | func storeExprDeclName() -> DeclReferenceExprSyntax? {
75 | expr.arguments.first(where: { $0.label?.text == "store" })?.expression.as(MemberAccessExprSyntax.self)?.declName
76 | }
77 |
78 | if let type = binding.typeAnnotation?.type.as(OptionalTypeSyntax.self), let keyExpr = keyExpr(), let storeName = storeExprDeclName() {
79 |
80 | guard defaultValueExpr() == nil else {
81 | throw ObservableUserDefaultArgumentError.optionalTypeShouldHaveNoDefaultValue
82 | }
83 |
84 | // Macro is attached to an optional type with an argument that contains no default value.
85 | return [
86 | #"""
87 | get {
88 | access(keyPath: \.\#(pattern))
89 | return UserDefaults.\#(storeName).value(forKey: \#(keyExpr)) as? \#(type.wrappedType)
90 | }
91 | """#,
92 | #"""
93 | set {
94 | withMutation(keyPath: \.\#(pattern)) {
95 | UserDefaults.\#(storeName).set(newValue, forKey: \#(keyExpr))
96 | }
97 | }
98 | """#
99 | ]
100 |
101 | } else if let type = binding.typeAnnotation?.type, let keyExpr = keyExpr(), let storeName = storeExprDeclName() {
102 |
103 | guard let defaultValueExpr = defaultValueExpr() else {
104 | throw ObservableUserDefaultArgumentError.nonOptionalTypeMustHaveDefaultValue
105 | }
106 |
107 | // Macro is attached to a non-optional type with an argument that contains a default value.
108 | return [
109 | #"""
110 | get {
111 | access(keyPath: \.\#(pattern))
112 | return UserDefaults.\#(storeName).value(forKey: \#(keyExpr)) as? \#(type) ?? \#(defaultValueExpr)
113 | }
114 | """#,
115 | #"""
116 | set {
117 | withMutation(keyPath: \.\#(pattern)) {
118 | UserDefaults.\#(storeName).set(newValue, forKey: \#(keyExpr))
119 | }
120 | }
121 | """#
122 | ]
123 |
124 | } else {
125 | throw ObservableUserDefaultArgumentError.unableToExtractRequiredValuesFromArgument
126 | }
127 | }
128 |
129 | }
130 |
131 | enum ObservableUserDefaultError: Error, CustomStringConvertible {
132 | case notVariableProperty
133 | case propertyMustContainOnlyOneBinding
134 | case propertyMustHaveNoAccessorBlock
135 | case propertyMustHaveNoInitializer
136 | case propertyMustUseSimplePatternSyntax
137 |
138 | var description: String {
139 | switch self {
140 | case .notVariableProperty:
141 | return "'@ObservableUserDefault' can only be applied to variables"
142 | case .propertyMustContainOnlyOneBinding:
143 | return "'@ObservableUserDefault' cannot be applied to multiple variable bindings"
144 | case .propertyMustHaveNoAccessorBlock:
145 | return "'@ObservableUserDefault' cannot be applied to computed properties"
146 | case .propertyMustHaveNoInitializer:
147 | return "'@ObservableUserDefault' cannot be applied to stored properties"
148 | case .propertyMustUseSimplePatternSyntax:
149 | return "'@ObservableUserDefault' can only be applied to a variables using simple declaration syntax, for example, 'var name: String'"
150 | }
151 | }
152 | }
153 |
154 | enum ObservableUserDefaultArgumentError: Error, CustomStringConvertible {
155 | case macroShouldOnlyContainOneArgument
156 | case nonOptionalTypeMustHaveDefaultValue
157 | case optionalTypeShouldHaveNoDefaultValue
158 | case unableToExtractRequiredValuesFromArgument
159 |
160 | var description: String {
161 | switch self {
162 | case .macroShouldOnlyContainOneArgument:
163 | return "Must provide an argument when using '@ObservableUserDefault' with parentheses"
164 | case .nonOptionalTypeMustHaveDefaultValue:
165 | return "'@ObservableUserDefault' arguments on non-optional types must provide default values"
166 | case .optionalTypeShouldHaveNoDefaultValue:
167 | return "'@ObservableUserDefault' arguments on optional types should not use default values"
168 | case .unableToExtractRequiredValuesFromArgument:
169 | return "'@ObservableUserDefault' unable to extract the required values from the argument"
170 | }
171 | }
172 | }
173 |
174 | @main
175 | struct ObservableUserDefaultPlugin: CompilerPlugin {
176 | let providingMacros: [Macro.Type] = [
177 | ObservableUserDefaultMacro.self
178 | ]
179 | }
180 |
--------------------------------------------------------------------------------
/Tests/ObservableUserDefaultTests/ObservableUserDefaultTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftSyntaxMacros
2 | import SwiftSyntaxMacrosTestSupport
3 | import XCTest
4 |
5 | // 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.
6 | #if canImport(ObservableUserDefaultMacros)
7 | import ObservableUserDefaultMacros
8 |
9 | let testMacros: [String: Macro.Type] = [
10 | "ObservableUserDefault": ObservableUserDefaultMacro.self,
11 | ]
12 | #endif
13 |
14 | final class ObservableUserDefaultTests: XCTestCase {
15 |
16 | func testObservableUserDefault() throws {
17 | #if canImport(ObservableUserDefaultMacros)
18 | assertMacroExpansion(
19 | #"""
20 | @Observable
21 | class StorageModel {
22 | @ObservableUserDefault
23 | @ObservationIgnored
24 | var name: String
25 | }
26 | """#,
27 | expandedSource:
28 | #"""
29 | @Observable
30 | class StorageModel {
31 | @ObservationIgnored
32 | var name: String {
33 | get {
34 | access(keyPath: \.name)
35 | return UserDefaults.name
36 | }
37 | set {
38 | withMutation(keyPath: \.name) {
39 | UserDefaults.name = newValue
40 | }
41 | }
42 | }
43 | }
44 | """#,
45 | macros: testMacros
46 | )
47 | #else
48 | throw XCTSkip("macros are only supported when running tests for the host platform")
49 | #endif
50 | }
51 |
52 | func testObservableUserDefaultWithArgumentOnNonOptionalType() throws {
53 | #if canImport(ObservableUserDefaultMacros)
54 | assertMacroExpansion(
55 | #"""
56 | @Observable
57 | class StorageModel {
58 | @ObservableUserDefault(.init(key: "NUMBER_STORAGE_KEY", defaultValue: 1000, store: .standard))
59 | @ObservationIgnored
60 | var number: Int
61 | }
62 | """#,
63 | expandedSource:
64 | #"""
65 | @Observable
66 | class StorageModel {
67 | @ObservationIgnored
68 | var number: Int {
69 | get {
70 | access(keyPath: \.number)
71 | return UserDefaults.standard.value(forKey: "NUMBER_STORAGE_KEY") as? Int ?? 1000
72 | }
73 | set {
74 | withMutation(keyPath: \.number) {
75 | UserDefaults.standard.set(newValue, forKey: "NUMBER_STORAGE_KEY")
76 | }
77 | }
78 | }
79 | }
80 | """#,
81 | macros: testMacros
82 | )
83 | #else
84 | throw XCTSkip("macros are only supported when running tests for the host platform")
85 | #endif
86 | }
87 |
88 | func testObservableUserDefaultWithIncorrectArgumentOnNonOptionalType() throws {
89 | #if canImport(ObservableUserDefaultMacros)
90 | assertMacroExpansion(
91 | #"""
92 | @Observable
93 | class StorageModel {
94 | @ObservableUserDefault(.init(key: "NUMBER_STORAGE_KEY", store: .standard))
95 | @ObservationIgnored
96 | var number: Int
97 | }
98 | """#,
99 | expandedSource:
100 | #"""
101 | @Observable
102 | class StorageModel {
103 | @ObservationIgnored
104 | var number: Int
105 | }
106 | """#,
107 | diagnostics: [
108 | DiagnosticSpec(message: "'@ObservableUserDefault' arguments on non-optional types must provide default values", line: 3, column: 5)
109 | ],
110 | macros: testMacros
111 | )
112 | #else
113 | throw XCTSkip("macros are only supported when running tests for the host platform")
114 | #endif
115 | }
116 |
117 | func testObservableUserDefaultWithArgumentOnOptionalType() throws {
118 | #if canImport(ObservableUserDefaultMacros)
119 | assertMacroExpansion(
120 | #"""
121 | @Observable
122 | class StorageModel {
123 | @ObservableUserDefault(.init(key: "NUMBER_STORAGE_KEY", store: .standard))
124 | @ObservationIgnored
125 | var number: Int?
126 | }
127 | """#,
128 | expandedSource:
129 | #"""
130 | @Observable
131 | class StorageModel {
132 | @ObservationIgnored
133 | var number: Int? {
134 | get {
135 | access(keyPath: \.number)
136 | return UserDefaults.standard.value(forKey: "NUMBER_STORAGE_KEY") as? Int
137 | }
138 | set {
139 | withMutation(keyPath: \.number) {
140 | UserDefaults.standard.set(newValue, forKey: "NUMBER_STORAGE_KEY")
141 | }
142 | }
143 | }
144 | }
145 | """#,
146 | macros: testMacros
147 | )
148 | #else
149 | throw XCTSkip("macros are only supported when running tests for the host platform")
150 | #endif
151 | }
152 |
153 | func testObservableUserDefaultWithIncorrectArgumentOnOptionalType() throws {
154 | #if canImport(ObservableUserDefaultMacros)
155 | assertMacroExpansion(
156 | #"""
157 | @Observable
158 | class StorageModel {
159 | @ObservableUserDefault(.init(key: "NUMBER_STORAGE_KEY", defaultValue: 1000, store: .standard))
160 | @ObservationIgnored
161 | var number: Int?
162 | }
163 | """#,
164 | expandedSource:
165 | #"""
166 | @Observable
167 | class StorageModel {
168 | @ObservationIgnored
169 | var number: Int?
170 | }
171 | """#,
172 | diagnostics: [
173 | DiagnosticSpec(message: "'@ObservableUserDefault' arguments on optional types should not use default values", line: 3, column: 5)
174 | ],
175 | macros: testMacros
176 | )
177 | #else
178 | throw XCTSkip("macros are only supported when running tests for the host platform")
179 | #endif
180 | }
181 |
182 | func testObservableUserDefaultWithConstant() throws {
183 | #if canImport(ObservableUserDefaultMacros)
184 | assertMacroExpansion(
185 | """
186 | @Observable
187 | class StorageModel {
188 | @ObservableUserDefault
189 | @ObservationIgnored
190 | let name: String
191 | }
192 | """,
193 | expandedSource:
194 | """
195 | @Observable
196 | class StorageModel {
197 | @ObservationIgnored
198 | let name: String
199 | }
200 | """,
201 | diagnostics: [
202 | DiagnosticSpec(message: "'@ObservableUserDefault' can only be applied to variables", line: 3, column: 5)
203 | ],
204 | macros: testMacros
205 | )
206 | #else
207 | throw XCTSkip("macros are only supported when running tests for the host platform")
208 | #endif
209 | }
210 |
211 | func testObservableUserDefaultWithComputedProperty() throws {
212 | #if canImport(ObservableUserDefaultMacros)
213 | assertMacroExpansion(
214 | """
215 | @Observable
216 | class StorageModel {
217 | @ObservableUserDefault
218 | @ObservationIgnored
219 | var name: String {
220 | return "John Appleseed"
221 | }
222 | }
223 | """,
224 | expandedSource:
225 | """
226 | @Observable
227 | class StorageModel {
228 | @ObservationIgnored
229 | var name: String {
230 | return "John Appleseed"
231 | }
232 | }
233 | """,
234 | diagnostics: [
235 | DiagnosticSpec(message: "'@ObservableUserDefault' cannot be applied to computed properties", line: 2, column: 5)
236 | ],
237 | macros: testMacros
238 | )
239 | #else
240 | throw XCTSkip("macros are only supported when running tests for the host platform")
241 | #endif
242 | }
243 |
244 | func testObservableUserDefaultWithStoredProperty() throws {
245 | #if canImport(ObservableUserDefaultMacros)
246 | assertMacroExpansion(
247 | """
248 | @Observable
249 | class StorageModel {
250 | @ObservableUserDefault
251 | @ObservationIgnored
252 | var name: String = "John Appleseed"
253 | }
254 | """,
255 | expandedSource:
256 | """
257 | @Observable
258 | class StorageModel {
259 | @ObservationIgnored
260 | var name: String = "John Appleseed"
261 | }
262 | """,
263 | diagnostics: [
264 | DiagnosticSpec(message: "'@ObservableUserDefault' cannot be applied to stored properties", line: 3, column: 5)
265 | ],
266 | macros: testMacros
267 | )
268 | #else
269 | throw XCTSkip("macros are only supported when running tests for the host platform")
270 | #endif
271 | }
272 |
273 | func testObservableUserDefaultWithArgumentOnNonOptionalArray() throws {
274 | #if canImport(ObservableUserDefaultMacros)
275 | assertMacroExpansion(
276 | #"""
277 | @Observable
278 | class StorageModel {
279 | @ObservableUserDefault(.init(key: "NAMES_STORAGE_KEY", defaultValue: ["Tim", "Craig"], store: .standard))
280 | @ObservationIgnored
281 | var names: [String]
282 | }
283 | """#,
284 | expandedSource:
285 | #"""
286 | @Observable
287 | class StorageModel {
288 | @ObservationIgnored
289 | var names: [String] {
290 | get {
291 | access(keyPath: \.names)
292 | return UserDefaults.standard.value(forKey: "NAMES_STORAGE_KEY") as? [String] ?? ["Tim", "Craig"]
293 | }
294 | set {
295 | withMutation(keyPath: \.names) {
296 | UserDefaults.standard.set(newValue, forKey: "NAMES_STORAGE_KEY")
297 | }
298 | }
299 | }
300 | }
301 | """#,
302 | macros: testMacros
303 | )
304 | #else
305 | throw XCTSkip("macros are only supported when running tests for the host platform")
306 | #endif
307 | }
308 |
309 | func testObservableUserDefaultWithIncorrectArgumentOnNonOptionalArray() throws {
310 | #if canImport(ObservableUserDefaultMacros)
311 | assertMacroExpansion(
312 | #"""
313 | @Observable
314 | class StorageModel {
315 | @ObservableUserDefault(.init(key: "NAMES_STORAGE_KEY", store: .standard))
316 | @ObservationIgnored
317 | var names: [String]
318 | }
319 | """#,
320 | expandedSource:
321 | #"""
322 | @Observable
323 | class StorageModel {
324 | @ObservationIgnored
325 | var names: [String]
326 | }
327 | """#,
328 | diagnostics: [
329 | DiagnosticSpec(message: "'@ObservableUserDefault' arguments on non-optional types must provide default values", line: 3, column: 5)
330 | ],
331 | macros: testMacros
332 | )
333 | #else
334 | throw XCTSkip("macros are only supported when running tests for the host platform")
335 | #endif
336 | }
337 |
338 | func testObservableUserDefaultWithArgumentOnOptionalArray() throws {
339 | #if canImport(ObservableUserDefaultMacros)
340 | assertMacroExpansion(
341 | #"""
342 | @Observable
343 | class StorageModel {
344 | @ObservableUserDefault(.init(key: "NAMES_STORAGE_KEY", store: .standard))
345 | @ObservationIgnored
346 | var names: [String]?
347 | }
348 | """#,
349 | expandedSource:
350 | #"""
351 | @Observable
352 | class StorageModel {
353 | @ObservationIgnored
354 | var names: [String]? {
355 | get {
356 | access(keyPath: \.names)
357 | return UserDefaults.standard.value(forKey: "NAMES_STORAGE_KEY") as? [String]
358 | }
359 | set {
360 | withMutation(keyPath: \.names) {
361 | UserDefaults.standard.set(newValue, forKey: "NAMES_STORAGE_KEY")
362 | }
363 | }
364 | }
365 | }
366 | """#,
367 | macros: testMacros
368 | )
369 | #else
370 | throw XCTSkip("macros are only supported when running tests for the host platform")
371 | #endif
372 | }
373 |
374 | func testObservableUserDefaultWithIncorrectArgumentOnOptionalArray() throws {
375 | #if canImport(ObservableUserDefaultMacros)
376 | assertMacroExpansion(
377 | #"""
378 | @Observable
379 | class StorageModel {
380 | @ObservableUserDefault(.init(key: "NAMES_STORAGE_KEY", defaultValue: ["Tim", "Craig"], store: .standard))
381 | @ObservationIgnored
382 | var names: [String]?
383 | }
384 | """#,
385 | expandedSource:
386 | #"""
387 | @Observable
388 | class StorageModel {
389 | @ObservationIgnored
390 | var names: [String]?
391 | }
392 | """#,
393 | diagnostics: [
394 | DiagnosticSpec(message: "'@ObservableUserDefault' arguments on optional types should not use default values", line: 3, column: 5)
395 | ],
396 | macros: testMacros
397 | )
398 | #else
399 | throw XCTSkip("macros are only supported when running tests for the host platform")
400 | #endif
401 | }
402 |
403 | }
404 |
--------------------------------------------------------------------------------