Skip to content

Commit

Permalink
Add @ObservableDefault macro (#189)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <[email protected]>
  • Loading branch information
kevinrpb and sindresorhus authored Nov 24, 2024
1 parent a89f799 commit ef1b231
Show file tree
Hide file tree
Showing 8 changed files with 630 additions and 1 deletion.
15 changes: 15 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"originHash" : "ab2612a1595aa1a4d9bb3f076279fda1b1b3d17525d1f97e45ce22c697728978",
"pins" : [
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
}
}
],
"version" : 3
}
38 changes: 38 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// swift-tools-version:5.11
import PackageDescription
import CompilerPluginSupport

let package = Package(
name: "Defaults",
Expand All @@ -16,8 +17,17 @@ let package = Package(
targets: [
"Defaults"
]
),
.library(
name: "DefaultsMacros",
targets: [
"DefaultsMacros"
]
)
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.1")
],
targets: [
.target(
name: "Defaults",
Expand All @@ -28,6 +38,18 @@ let package = Package(
// .swiftLanguageMode(.v5)
// ]
),
.macro(
name: "DefaultsMacrosDeclarations",
dependencies: [
"Defaults",
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
.target(
name: "DefaultsMacros",
dependencies: ["Defaults", "DefaultsMacrosDeclarations"]
),
.testTarget(
name: "DefaultsTests",
dependencies: [
Expand All @@ -36,6 +58,22 @@ let package = Package(
// swiftSettings: [
// .swiftLanguageMode(.v5)
// ]
),
.testTarget(
name: "DefaultsMacrosDeclarationsTests",
dependencies: [
"DefaultsMacros",
"DefaultsMacrosDeclarations",
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
]
),
.testTarget(
name: "DefaultsMacrosTests",
dependencies: [
"Defaults",
"DefaultsMacros"
]
)
]
)
48 changes: 48 additions & 0 deletions Sources/DefaultsMacros/ObservableDefault.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Defaults
import Foundation

/**
Attached macro that adds support for using ``Defaults`` in ``@Observable`` classes.
- Important: To prevent issues with ``@Observable``, you need to also add ``@ObservationIgnored`` to the attached property.
This macro adds accessor blocks to the attached property similar to those added by `@Observable`.
For example, given the following source:
```swift
@Observable
final class CatModel {
@ObservableDefault(.cat)
@ObservationIgnored
var catName: String
}
```
The macro will generate the following expansion:
```swift
@Observable
final class CatModel {
@ObservationIgnored
var catName: String {
get {
access(keypath: \.catName)
return Defaults[.cat]
}
set {
withMutation(keyPath: \catName) {
Defaults[.cat] = newValue
}
}
}
}
```
*/
@attached(accessor, names: named(get), named(set))
@attached(peer, names: prefixed(`_objcAssociatedKey_`))
public macro ObservableDefault<Value>(_ key: Defaults.Key<Value>) =
#externalMacro(
module: "DefaultsMacrosDeclarations",
type: "ObservableDefaultMacro"
)
9 changes: 9 additions & 0 deletions Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct DefaultsMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
ObservableDefaultMacro.self
]
}
191 changes: 191 additions & 0 deletions Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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`"
}
}
}
Loading

0 comments on commit ef1b231

Please sign in to comment.