├── .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 | --------------------------------------------------------------------------------