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