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

Implement @ObservableDefault macro #189

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
15 changes: 15 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"originHash" : "1ba60ebd54db82e47e39bc8db179589187c069067eb0a8cd6ec19d2301c5abc4",
"pins" : [
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25",
"version" : "600.0.0"
}
}
],
"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.10
import PackageDescription
import CompilerPluginSupport

let package = Package(
name: "Defaults",
Expand All @@ -16,18 +17,55 @@ let package = Package(
targets: [
"Defaults"
]
),
.library(
name: "DefaultsMacros",
targets: [
"DefaultsMacros"
]
)
],
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0")
],
targets: [
.target(
name: "Defaults",
resources: [.copy("PrivacyInfo.xcprivacy")]
),
.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: [
"Defaults"
]
),
.testTarget(
name: "DefaultsMacrosDeclarationsTests",
dependencies: [
"DefaultsMacros",
"DefaultsMacrosDeclarations",
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
]
),
.testTarget(
name: "DefaultsMacrosTests",
dependencies: [
"Defaults",
"DefaultsMacros"
]
)
]
)
47 changes: 47 additions & 0 deletions Sources/DefaultsMacros/ObservableDefault.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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))
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
]
}
117 changes: 117 additions & 0 deletions Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import SwiftCompilerPlugin
import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct ObservableDefaultMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
) throws -> [AccessorDeclSyntax] {
) throws(ObservableDefaultMacroError) -> [AccessorDeclSyntax] {

And then you can remove ObservableDefaultMacroError from the throw statements.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! typed throws are here and I forgot about them. Neat.

// Must be attached to a property declaration.
guard let variableDeclaration = declaration.as(VariableDeclSyntax.self) else {
throw ObservableDefaultMacroError.notAttachedToProperty
}

// Must be attached to a variable property (i.e. `var` and not `let`).
guard variableDeclaration.bindingSpecifier.tokenKind == .keyword(.var) else {
throw ObservableDefaultMacroError.notAttachedToVariable
}

// Must be attached to a single property.
guard variableDeclaration.bindings.count == 1, let binding = variableDeclaration.bindings.first else {
throw ObservableDefaultMacroError.notAttachedToSingleProperty
}

// Must not provide an initializer for the property (i.e. not assign a value).
guard binding.initializer == nil else {
throw ObservableDefaultMacroError.attachedToPropertyWithInitializer
}

// Must not be attached to property with existing accessor block.
guard binding.accessorBlock == nil else {
throw ObservableDefaultMacroError.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 ObservableDefaultMacroError.attachedToPropertyWithoutIdentifierProperty
}

// Must receive arguments
guard let arguments = node.arguments else {
throw ObservableDefaultMacroError.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 ObservableDefaultMacroError.calledWithoutLabeledExpression
}

// Must only receive one argument.
guard expressionList.count == 1, let expression = expressionList.first?.expression else {
throw ObservableDefaultMacroError.calledWithMultipleArguments
}

return [
#"""
get {
access(keyPath: \.\#(pattern))
return Defaults[\#(expression)]
}
"""#,
#"""
set {
withMutation(keyPath: \.\#(pattern)) {
Defaults[\#(expression)] = newValue
}
}
"""#
]
}
}

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