Defaults/Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift

192 lines
6.4 KiB
Swift
Raw Normal View History

import SwiftCompilerPlugin
import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
/**
Macro declaration for the ``ObservableDefault`` macro.
*/
public struct ObservableDefaultMacro {}
/**
Conforming to ``AccessorMacro`` allows us to add the property accessors (get/set) that integrate with ``Observable``.
*/
extension ObservableDefaultMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws(ObservableDefaultMacroError) -> [AccessorDeclSyntax] {
let property = try propertyPattern(of: declaration)
let expression = try keyExpression(of: node)
let associatedKey = associatedKeyToken(for: property)
// The get/set accessors follow the same pattern that @Observable uses to handle the mutations.
//
// The get accessor also sets up an observation to update the value when the UserDefaults
// changes from elsewhere. Doing so requires attaching it as an Objective-C associated
// object due to limitations with current macro capabilities and Swift concurrency.
return [
#"""
get {
if objc_getAssociatedObject(self, &Self.\#(associatedKey)) == nil {
let cancellable = Defaults.publisher(\#(expression))
.sink { [weak self] in
self?.\#(property) = $0.newValue
}
objc_setAssociatedObject(self, &Self.\#(associatedKey), cancellable, .OBJC_ASSOCIATION_RETAIN)
}
access(keyPath: \.\#(property))
return Defaults[\#(expression)]
}
"""#,
#"""
set {
withMutation(keyPath: \.\#(property)) {
Defaults[\#(expression)] = newValue
}
}
"""#
]
}
}
/**
Conforming to ``PeerMacro`` we can add a new property of type Defaults.Observation that will update the original property whenever
the UserDefaults value changes outside the class.
*/
extension ObservableDefaultMacro: PeerMacro {
public static func expansion(
of node: SwiftSyntax.AttributeSyntax,
providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol,
in context: some SwiftSyntaxMacros.MacroExpansionContext
) throws -> [SwiftSyntax.DeclSyntax] {
let property = try propertyPattern(of: declaration)
let associatedKey = associatedKeyToken(for: property)
return [
"private nonisolated(unsafe) static var \(associatedKey): Void?"
]
}
}
// Logic used by both macro implementations
extension ObservableDefaultMacro {
/**
Extracts the pattern (i.e. the name) of the attached property.
*/
private static func propertyPattern(
of declaration: some SwiftSyntax.DeclSyntaxProtocol
) throws(ObservableDefaultMacroError) -> TokenSyntax {
// Must be attached to a property declaration.
guard let variableDeclaration = declaration.as(VariableDeclSyntax.self) else {
throw .notAttachedToProperty
}
// Must be attached to a variable property (i.e. `var` and not `let`).
guard variableDeclaration.bindingSpecifier.tokenKind == .keyword(.var) else {
throw .notAttachedToVariable
}
// Must be attached to a single property.
guard variableDeclaration.bindings.count == 1, let binding = variableDeclaration.bindings.first else {
throw .notAttachedToSingleProperty
}
// Must not provide an initializer for the property (i.e. not assign a value).
guard binding.initializer == nil else {
throw .attachedToPropertyWithInitializer
}
// Must not be attached to property with existing accessor block.
guard binding.accessorBlock == nil else {
throw .attachedToPropertyWithAccessorBlock
}
// Must use Identifier Pattern.
// See https://swiftinit.org/docs/swift-syntax/swiftsyntax/identifierpatternsyntax
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier else {
throw .attachedToPropertyWithoutIdentifierProperty
}
return pattern
}
/**
Extracts the expression used to define the Defaults.Key in the macro call.
*/
private static func keyExpression(
of node: AttributeSyntax
) throws(ObservableDefaultMacroError) -> ExprSyntax {
// Must receive arguments
guard let arguments = node.arguments else {
throw .calledWithoutArguments
}
// Must be called with Labeled Expression.
// See https://swiftinit.org/docs/swift-syntax/swiftsyntax/labeledexprlistsyntax
guard let expressionList = arguments.as(LabeledExprListSyntax.self) else {
throw .calledWithoutLabeledExpression
}
// Must only receive one argument.
guard expressionList.count == 1, let expression = expressionList.first?.expression else {
throw .calledWithMultipleArguments
}
return expression
}
/**
Generates the token to use as key for the associated object used to hold the UserDefaults observation.
*/
private static func associatedKeyToken(for property: TokenSyntax) -> TokenSyntax {
"_objcAssociatedKey_\(property)"
}
}
/**
Error handling for ``ObservableDefaultMacro``.
*/
public enum ObservableDefaultMacroError: Error {
case notAttachedToProperty
case notAttachedToVariable
case notAttachedToSingleProperty
case attachedToPropertyWithInitializer
case attachedToPropertyWithAccessorBlock
case attachedToPropertyWithoutIdentifierProperty
case calledWithoutArguments
case calledWithoutLabeledExpression
case calledWithMultipleArguments
case calledWithoutFunctionSyntax
case calledWithoutKeyArgument
case calledWithUnsupportedExpression
}
extension ObservableDefaultMacroError: CustomStringConvertible {
public var description: String {
switch self {
case .notAttachedToProperty:
"@ObservableDefault must be attached to a property."
case .notAttachedToVariable:
"@ObservableDefault must be attached to a `var` property."
case .notAttachedToSingleProperty:
"@ObservableDefault can only be attached to a single property."
case .attachedToPropertyWithInitializer:
"@ObservableDefault must not be attached with a property with a value assigned. To create set default value, provide it in the `Defaults.Key` definition."
case .attachedToPropertyWithAccessorBlock:
"@ObservableDefault must not be attached to a property with accessor block."
case .attachedToPropertyWithoutIdentifierProperty:
"@ObservableDefault could not identify the attached property."
case .calledWithoutArguments,
.calledWithoutLabeledExpression,
.calledWithMultipleArguments,
.calledWithoutFunctionSyntax,
.calledWithoutKeyArgument,
.calledWithUnsupportedExpression:
"@ObservableDefault must be called with (1) argument of type `Defaults.Key`"
}
}
}