-
-
Notifications
You must be signed in to change notification settings - Fork 121
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Sindre Sorhus <[email protected]>
- Loading branch information
1 parent
a89f799
commit ef1b231
Showing
8 changed files
with
630 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
9
Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
191
Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`" | ||
} | ||
} | ||
} |
Oops, something went wrong.