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/Default.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`.

sindresorhus marked this conversation as resolved.
Show resolved Hide resolved
For example, given the following source:

```swift
@Observable
final class CatModel {
@Default(.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 Default<Value>(_ key: Defaults.Key<Value>) =
#externalMacro(
module: "DefaultsMacrosDeclarations",
type: "DefaultMacro"
)
117 changes: 117 additions & 0 deletions Sources/DefaultsMacrosDeclarations/DefaultMacro.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 DefaultMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
// Must be attached to a property declaration.
guard let variableDeclaration = declaration.as(VariableDeclSyntax.self) else {
throw DefaultMacroError.notAttachedToProperty
}

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

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

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

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

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

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

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

enum DefaultMacroError: 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 DefaultMacroError: CustomStringConvertible {
var description: String {
switch self {
case .notAttachedToProperty:
"@Default must be attached to a property."
case .notAttachedToVariable:
"@Default must be attached to a `var` property."
case .notAttachedToSingleProperty:
"@Default can only be attached to a single property."
case .attachedToPropertyWithInitializer:
"@Default 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:
"@Default must not be attached to a property with accessor block."
case .attachedToPropertyWithoutIdentifierProperty:
"@Default could not identify the attached property."
case .calledWithoutArguments,
.calledWithoutLabeledExpression,
.calledWithMultipleArguments,
.calledWithoutFunctionSyntax,
.calledWithoutKeyArgument,
.calledWithUnsupportedExpression:
"@Default must be called with (1) argument of type `Defaults.Key`"
}
}
}
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] = [
DefaultMacro.self
]
}
Loading
Loading