diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..7f3ac9b --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift index fe3d4bf..e1cb708 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ // swift-tools-version:5.10 import PackageDescription +import CompilerPluginSupport let package = Package( name: "Defaults", @@ -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" + ] ) ] ) diff --git a/Sources/DefaultsMacros/ObservableDefault.swift b/Sources/DefaultsMacros/ObservableDefault.swift new file mode 100644 index 0000000..ed3e1db --- /dev/null +++ b/Sources/DefaultsMacros/ObservableDefault.swift @@ -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(_ key: Defaults.Key) = + #externalMacro( + module: "DefaultsMacrosDeclarations", + type: "ObservableDefaultMacro" + ) diff --git a/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift b/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift new file mode 100644 index 0000000..689f41d --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/DefaultsMacrosPlugin.swift @@ -0,0 +1,9 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct DefaultsMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + ObservableDefaultMacro.self + ] +} diff --git a/Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift b/Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift new file mode 100644 index 0000000..720254a --- /dev/null +++ b/Sources/DefaultsMacrosDeclarations/ObservableDefaultMacro.swift @@ -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] { + // 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`" + } + } +} diff --git a/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift b/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift new file mode 100644 index 0000000..2dce238 --- /dev/null +++ b/Tests/DefaultsMacrosDeclarationsTests/ObservableDefaultMacroTests.swift @@ -0,0 +1,164 @@ +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. +// Cross-compiled tests may still make use of the macro itself in end-to-end tests. +#if canImport(DefaultsMacrosDeclarations) +@testable import DefaultsMacros +@testable import DefaultsMacrosDeclarations + +let testMacros: [String: Macro.Type] = [ + "ObservableDefault": ObservableDefaultMacro.self +] +#endif + +final class ObservableDefaultMacroTests: XCTestCase { + func testExpansionWithMemberSyntax() throws { + #if canImport(DefaultsMacrosDeclarations) + assertMacroExpansion( + #""" + @Observable + class ObservableClass { + @ObservableDefault(Defaults.Keys.name) + @ObservationIgnored + var name: String + } + """#, + expandedSource: + #""" + @Observable + class ObservableClass { + @ObservationIgnored + var name: String { + get { + access(keyPath: \.name) + return Defaults[Defaults.Keys.name] + } + set { + withMutation(keyPath: \.name) { + Defaults[Defaults.Keys.name] = newValue + } + } + } + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + #else + throw XCTSkip("Macros are only supported when running tests for the host platform") + #endif + } + + func testExpansionWithDotSyntax() throws { + #if canImport(DefaultsMacrosDeclarations) + assertMacroExpansion( + #""" + @Observable + class ObservableClass { + @ObservableDefault(.name) + @ObservationIgnored + var name: String + } + """#, + expandedSource: + #""" + @Observable + class ObservableClass { + @ObservationIgnored + var name: String { + get { + access(keyPath: \.name) + return Defaults[.name] + } + set { + withMutation(keyPath: \.name) { + Defaults[.name] = newValue + } + } + } + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + #else + throw XCTSkip("Macros are only supported when running tests for the host platform") + #endif + } + + func testExpansionWithFunctionCall() throws { + #if canImport(DefaultsMacrosDeclarations) + assertMacroExpansion( + #""" + @Observable + class ObservableClass { + @ObservableDefault(getName()) + @ObservationIgnored + var name: String + } + """#, + expandedSource: + #""" + @Observable + class ObservableClass { + @ObservationIgnored + var name: String { + get { + access(keyPath: \.name) + return Defaults[getName()] + } + set { + withMutation(keyPath: \.name) { + Defaults[getName()] = newValue + } + } + } + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + #else + throw XCTSkip("Macros are only supported when running tests for the host platform") + #endif + } + + func testExpansionWithProperty() throws { + #if canImport(DefaultsMacrosDeclarations) + assertMacroExpansion( + #""" + @Observable + class ObservableClass { + @ObservableDefault(propertyName) + @ObservationIgnored + var name: String + } + """#, + expandedSource: + #""" + @Observable + class ObservableClass { + @ObservationIgnored + var name: String { + get { + access(keyPath: \.name) + return Defaults[propertyName] + } + set { + withMutation(keyPath: \.name) { + Defaults[propertyName] = newValue + } + } + } + } + """#, + macros: testMacros, + indentationWidth: .tabs(1) + ) + #else + throw XCTSkip("Macros are only supported when running tests for the host platform") + #endif + } +} diff --git a/Tests/DefaultsMacrosTests/ObservableDefaultTests.swift b/Tests/DefaultsMacrosTests/ObservableDefaultTests.swift new file mode 100644 index 0000000..a1243e7 --- /dev/null +++ b/Tests/DefaultsMacrosTests/ObservableDefaultTests.swift @@ -0,0 +1,107 @@ +import XCTest + +import Defaults +@testable import DefaultsMacros + +private let testKey = "testKey" +private let defaultValue = "defaultValue" +private let newValue = "newValue" + +extension Defaults.Keys { + static let test = Defaults.Key(testKey, default: defaultValue) +} + +func getKey() -> Defaults.Key { + .test +} + +let keyProperty = Defaults.Keys.test + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +private final class TestModelWithMemberSyntax { + @ObservableDefault(Defaults.Keys.test) + @ObservationIgnored + var testValue: String +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +private final class TestModelWithDotSyntax { + @ObservableDefault(.test) + @ObservationIgnored + var testValue: String +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +private final class TestModelWithFunctionCall { + @ObservableDefault(getKey()) + @ObservationIgnored + var testValue: String +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +@Observable +final class TestModelWithProperty { + @ObservableDefault(keyProperty) + @ObservationIgnored + var testValue: String +} + +@available(macOS 14, iOS 17, tvOS 17, watchOS 10, visionOS 1, *) +final class ObservableDefaultTests: XCTestCase { + override func setUp() { + super.setUp() + Defaults[.test] = defaultValue + } + + override func tearDown() { + super.tearDown() + Defaults.removeAll() + } + + func testMacroWithMemberSyntax() { + let model = TestModelWithMemberSyntax() + XCTAssertEqual(model.testValue, defaultValue) + + let userDefaultsValue = UserDefaults.standard.string(forKey: testKey) + XCTAssertEqual(userDefaultsValue, defaultValue) + + UserDefaults.standard.set(newValue, forKey: testKey) + XCTAssertEqual(model.testValue, newValue) + } + + func testMacroWithDotSyntax() { + let model = TestModelWithDotSyntax() + XCTAssertEqual(model.testValue, defaultValue) + + let userDefaultsValue = UserDefaults.standard.string(forKey: testKey) + XCTAssertEqual(userDefaultsValue, defaultValue) + + UserDefaults.standard.set(newValue, forKey: testKey) + XCTAssertEqual(model.testValue, newValue) + } + + func testMacroWithFunctionCall() { + let model = TestModelWithFunctionCall() + XCTAssertEqual(model.testValue, defaultValue) + + let userDefaultsValue = UserDefaults.standard.string(forKey: testKey) + XCTAssertEqual(userDefaultsValue, defaultValue) + + UserDefaults.standard.set(newValue, forKey: testKey) + XCTAssertEqual(model.testValue, newValue) + } + + func testMacroWithProperty() { + let model = TestModelWithProperty() + XCTAssertEqual(model.testValue, defaultValue) + + let userDefaultsValue = UserDefaults.standard.string(forKey: testKey) + XCTAssertEqual(userDefaultsValue, defaultValue) + + UserDefaults.standard.set(newValue, forKey: testKey) + XCTAssertEqual(model.testValue, newValue) + } +} diff --git a/readme.md b/readme.md index 3dafd90..03022eb 100644 --- a/readme.md +++ b/readme.md @@ -181,7 +181,7 @@ Defaults[isUnicorn] ### SwiftUI support -#### `@Default` +#### `@Default` in `View` You can use the `@Default` property wrapper to get/set a `Defaults` item and also have the view be updated when the value changes. This is similar to `@State`. @@ -207,6 +207,27 @@ Note that it's `@Default`, not `@Defaults`. You cannot use `@Default` in an `ObservableObject`. It's meant to be used in a `View`. +#### `@ObservableDefault` in `@Observable` + +With the `@ObservableDefault` macro, you can use `Defaults` inside `@Observable` classes that use the [Observation](https://developer.apple.com/documentation/observation) framework. Doing so is as simple as importing `DefaultsMacros` and adding two lines to a property (note that adding `@ObservationIgnored` is needed to prevent clashes with `@Observable`): + +> [!IMPORTANT] +> Build times will increase when using macros. +> +> Swift macros depend on the [`swift-syntax`](https://github.com/swiftlang/swift-syntax) package. This means that when you compile code that includes macros as dependencies, you also have to compile `swift-syntax`. It is widely known that doing so has serious impact in build time and, while it is an issue that is being tracked (see [`swift-syntax`#2421](https://github.com/swiftlang/swift-syntax/issues/2421)), there's currently no solution implemented. + +```swift +import Defaults +import DefaultsMacros + +@Observable +final class UnicornManager { + @ObservableDefault(.hasUnicorn) + @ObservationIgnored + var hasUnicorn: Bool +} +``` + #### `Toggle` There's also a `SwiftUI.Toggle` wrapper that makes it easier to create a toggle based on a `Defaults` key with a `Bool` value.