Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @ObservableDefault macro #189

Merged
merged 20 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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