diff --git a/Package.resolved b/Package.resolved index 4f0f6a5..b655792 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,14 @@ { - "originHash" : "453b1b07ccf3377a8ca768fdd4cbdacbda97ad34b91e17e1f11b3f9f4013c9a6", "pins" : [ { "identity" : "swift-syntax", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", - "version" : "509.1.1" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } } ], - "version" : 3 + "version" : 2 } diff --git a/Package.swift b/Package.swift index ff4817d..3c051a3 100644 --- a/Package.swift +++ b/Package.swift @@ -8,12 +8,16 @@ let package = Package( platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)], products: [ .library( - name: "FuturedMacros", + name: "EnumIdentable", targets: ["EnumIdentable"] - ) + ), + .library( + name: "DataCache", + targets: ["DataCache"] + ), ], dependencies: [ - .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"), + .package(url: "https://github.com/apple/swift-syntax.git", from: "600.0.1"), ], targets: [ // Macro implementation that performs the source transformation of a macro. @@ -24,9 +28,17 @@ let package = Package( .product(name: "SwiftCompilerPlugin", package: "swift-syntax") ] ), + .macro( + name: "DataCacheMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), // Library that exposes a macro as part of its API, which is used in client programs. .target(name: "EnumIdentable", dependencies: ["EnumIdentableMacros"]), + .target(name: "DataCache", dependencies: ["DataCacheMacros"]), // A test target used to develop the macro implementation. .testTarget( @@ -36,5 +48,13 @@ let package = Package( .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), ] ), + + .testTarget( + name: "DataCacheTests", + dependencies: [ + "DataCacheMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), ] ) diff --git a/Sources/DataCache/DataCache.swift b/Sources/DataCache/DataCache.swift new file mode 100644 index 0000000..5959560 --- /dev/null +++ b/Sources/DataCache/DataCache.swift @@ -0,0 +1,67 @@ +// +// DataCache.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 13.01.2025. +// + +public protocol VersionedDataCache { + associatedtype _Versions: Hashable +} + +public final class SubscriptionBox { + public init( + initialVersion: C._Versions, + continuation: AsyncStream.Continuation, + predicate: @escaping (_ oldValue: C._Versions, _ newValue: C._Versions) -> Bool + ) { + self.lastVersion = initialVersion + self.yield = continuation.yield + self.predicate = predicate + } + + private let yield: (sending C) -> AsyncStream.Continuation.YieldResult + private let predicate: (_ oldValue: C._Versions, _ newValue: C._Versions) -> Bool + private var lastVersion: C._Versions + + public func emmit(cache: C, ifDiffers newVersion: C._Versions) { + defer { + lastVersion = newVersion + } + if predicate(lastVersion, newVersion) { + _ = yield(cache) + } + } +} + +public protocol ProxyObject { + associatedtype Ref: AnyObject +} + +public protocol ProxySettable { + associatedtype Proxy: ProxyObject +} + +@attached(member, names: named(_version), named(_subscribtions), named(makeSubscriber), named(applyChanges), named(_Versions)) +@attached(memberAttribute) +@attached(extension, conformances: VersionedDataCache) +public macro DataCache(isolation: GA.Type? = Optional.none) = #externalMacro( + module: "DataCacheMacros", + type: "DataCacheMacro" +) + +@attached(extension, conformances: ProxySettable, names: named(Proxy), named(withTransaction)) +public macro ProxySetter(isolation: GA.Type? = Optional.none) = #externalMacro( + module: "DataCacheMacros", + type: "ProxySetterMacro" +) + +@attached(accessor, names: named(init), named(get), named(set)) +@attached(peer, names: prefixed(`_`)) +public macro VersionedProperty() = #externalMacro(module: "DataCacheMacros", type: "VersionedPropertyMacro") + +@freestanding(expression) +public macro cacheSubscribtion(on: T, properties: repeat KeyPath) -> AsyncStream = #externalMacro( + module: "DataCacheMacros", + type: "CacheSubscriptionMacro" +) diff --git a/Sources/DataCacheMacros/CacheSubscriptionMacro.swift b/Sources/DataCacheMacros/CacheSubscriptionMacro.swift new file mode 100644 index 0000000..fb1b1bc --- /dev/null +++ b/Sources/DataCacheMacros/CacheSubscriptionMacro.swift @@ -0,0 +1,65 @@ +// +// CacheSubscriptionMacro.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 13.01.2025. +// +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics +import Foundation + +public struct CacheSubscriptionMacro: ExpressionMacro { + public static func expansion( + of node: some FreestandingMacroExpansionSyntax, + in context: some MacroExpansionContext + ) -> ExprSyntax { + var cacheNameExr: String? + var properties: [String] = [] + + for listItem in node.arguments { + if cacheNameExr == nil { + if + listItem.label?.text == "on" + { + cacheNameExr = "\(listItem.expression)" + } else { + return ExprSyntax("") + } + } else if properties.count == 0 { + if + listItem.label?.text == "properties", + let kpExpr = listItem.expression.as(KeyPathExprSyntax.self) + { + properties = ["\(kpExpr.components)"] + } else { + return ExprSyntax("") + } + } else { + if + let kpExpr = listItem.expression.as(KeyPathExprSyntax.self) + { + properties.append("\(kpExpr.components)") + } else { + return ExprSyntax("") + } + } + } + + guard let cacheNameExr, !properties.isEmpty else { + return ExprSyntax("") + } + + return ExprSyntax( + """ + \(raw: cacheNameExr).makeSubscriber( + predicate: { + \( raw: properties.map { "$0\($0) != $1\($0)" }.joined(separator: " || ") ) + } + ) + """ + ) + } +} diff --git a/Sources/DataCacheMacros/DataCacheMacro.swift b/Sources/DataCacheMacros/DataCacheMacro.swift new file mode 100644 index 0000000..e08f541 --- /dev/null +++ b/Sources/DataCacheMacros/DataCacheMacro.swift @@ -0,0 +1,188 @@ +// +// DataCacheMacro.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 13.01.2025. +// + +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics +import Foundation + +public struct DataCacheMacro: MemberMacro { + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + providingMembersOf declaration: Declaration, + in context: Context + ) throws -> [SwiftSyntax.DeclSyntax] where + Declaration : SwiftSyntax.DeclGroupSyntax, + Context : SwiftSyntaxMacros.MacroExpansionContext + { + let attributeActor = node.arguments.flatMap { arguments -> String? in + let isolationArg = arguments.as(LabeledExprListSyntax.self)?.first { labeledExpr in + labeledExpr.label?.text == "isolation" + } + + guard let memberAccess = isolationArg?.expression.as(MemberAccessExprSyntax.self) else { + return nil + } + + guard let base = memberAccess.base else { + return nil + } + + return "\(base)" + } + + + return makeVersionsStruct(members: declaration.memberBlock.members) + + emmitSubsriptionSupport(className: className(decl: declaration), actor: attributeActor) + + emmitTransactionSupport(className: className(decl: declaration)) + } + + private static func className(decl: Declaration) -> String! { + guard let clsDecl = decl.as(ClassDeclSyntax.self) else { + return nil + } + + return clsDecl.name.text + } + + // if some variables are undesirable, you can add filter to this method + private static func storedVariable(members: MemberBlockItemListSyntax) -> [VariableDeclSyntax] { + members.compactMap { member in + guard + let varDecl = member.decl.as(VariableDeclSyntax.self) + else { + return nil + } + + return varDecl + } + } + + private static func storedVariableNames(members: MemberBlockItemListSyntax) -> [String] { + storedVariable(members: members).compactMap { varDecl in + guard + let ident = varDecl.bindings.first?.pattern.as(IdentifierPatternSyntax.self) + else { + return nil + } + + return ident.identifier.text + } + } + + private static func makeVersionsStruct(members: MemberBlockItemListSyntax) -> [DeclSyntax] { + [ + DeclSyntax( + """ + struct _Versions: Hashable { + \(raw: storedVariableNames(members: members).map { " var \($0): UInt = 0" }.joined(separator: "\n") ) + } + """ + ), + DeclSyntax("private var _version: _Versions = .init()"), + ] + } + + private static func emmitSubsriptionSupport(className: String, actor: String?) -> [DeclSyntax] { + var makePattern = + """ + func makeSubscriber(predicate: @escaping (_ oldValue: _Versions, _ newValue: _Versions) -> Bool) -> AsyncStream<\(className)> { + let (stream, continuation) = AsyncStream.makeStream(of: \(className).self) + let box = SubscriptionBox( + initialVersion: self._version, + continuation: continuation, + predicate: predicate + ) + + continuation.onTermination = { [weak self] _ in + + """ + if let actor { + makePattern += + """ + Task { @\(actor) in + self?._subscribtions.removeAll { + $0 === box + } + } + """ + } else { + makePattern += + """ + self?._subscribtions.removeAll { + $0 === box + } + """ + } + makePattern += + """ + } + defer { continuation.yield(self) } + self._subscribtions.append(box) + return stream + } + """ + + return [ + DeclSyntax("private var _subscribtions: [SubscriptionBox<\(raw: className)>] = []"), + DeclSyntax(stringLiteral: makePattern) + ] + } + + private static func emmitTransactionSupport(className: String) -> [DeclSyntax] { + return [ + DeclSyntax( + """ + func applyChanges(during block: () -> Void) { + block() + for subscribtion in _subscribtions { + subscribtion.emmit(cache: self, ifDiffers: self._version) + } + } + """ + ) + ] + } +} + +extension DataCacheMacro: MemberAttributeMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + guard member.is(VariableDeclSyntax.self) else { return [] } + return ["@VersionedProperty"] + } +} + +extension DataCacheMacro: ExtensionMacro { + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, + providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, + conformingTo protocols: [SwiftSyntax.TypeSyntax], + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { + guard let classDecl = declaration.as(ClassDeclSyntax.self) else { + return [] + } + + return [ + try? ExtensionDeclSyntax( + """ + extension \(raw: classDecl.name.text): VersionedDataCache { } + """ + ) + ].compactMap(\.self) + } + + +} diff --git a/Sources/DataCacheMacros/Plugin.swift b/Sources/DataCacheMacros/Plugin.swift new file mode 100644 index 0000000..6d93269 --- /dev/null +++ b/Sources/DataCacheMacros/Plugin.swift @@ -0,0 +1,22 @@ +// +// CacheMain.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 13.01.2025. +// +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics +import Foundation + +@main +struct DataCachePlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + DataCacheMacro.self, + CacheSubscriptionMacro.self, + VersionedPropertyMacro.self, + ProxySetterMacro.self + ] +} diff --git a/Sources/DataCacheMacros/ProxySetterMacro.swift b/Sources/DataCacheMacros/ProxySetterMacro.swift new file mode 100644 index 0000000..6f111c2 --- /dev/null +++ b/Sources/DataCacheMacros/ProxySetterMacro.swift @@ -0,0 +1,124 @@ +// +// ProxySetterMacro.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 15.01.2025. +// + +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics +import Foundation + +public struct ProxySetterMacro: ExtensionMacro { + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, + providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, + conformingTo protocols: [SwiftSyntax.TypeSyntax], + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { + guard let classDecl = declaration.as(ClassDeclSyntax.self) else { + return [] + } + + let attributeActor = node.arguments.flatMap { arguments -> String? in + let isolationArg = arguments.as(LabeledExprListSyntax.self)?.first { labeledExpr in + labeledExpr.label?.text == "isolation" + } + + guard let memberAccess = isolationArg?.expression.as(MemberAccessExprSyntax.self) else { + return nil + } + + guard let base = memberAccess.base else { + return nil + } + + return "\(base)" + } + + let variableDeclarations = storedVariable( + members: declaration.memberBlock.members + ).compactMap(emmitProxyVariable(storedVariable:)) + + + var declStr = + """ + final class Proxy: ProxyObject { + typealias Ref = \(classDecl.name.text) + + private var ref: \(classDecl.name.text) + + init(ref: \(classDecl.name.text)) { + self.ref = ref + } + + \(variableDeclarations.joined(separator: "\n\n")) + } + """ + + if let attributeActor { + declStr = " @\(attributeActor)\n" + declStr + } + + let transactionDecl = + """ + func withTransaction(in block: (Proxy) -> Void) { + self.applyChanges { + block(Proxy(ref: self)) + } + } + """ + + return [ + try? ExtensionDeclSyntax( + """ + extension \(raw: classDecl.name.text): ProxySettable { + \(raw: declStr) + + \(raw: transactionDecl) + } + """ + ) + ].compactMap(\.self) + } + + private static func storedVariable(members: MemberBlockItemListSyntax) -> [VariableDeclSyntax] { + members.compactMap { member in + guard + let varDecl = member.decl.as(VariableDeclSyntax.self) + else { + return nil + } + + return varDecl + } + } + + private static func emmitProxyVariable(storedVariable: VariableDeclSyntax) -> String? { + guard + let binding = storedVariable.bindings.first, + let name = binding.pattern.as(IdentifierPatternSyntax.self), + let type = binding.typeAnnotation?.type, + !"\(name)".hasPrefix("_") + else { + return nil + } + + return """ + var \(name): \(type) { + get { + ref.\(name) + } + set { + ref.\(name) = newValue + } + } + """ + } + + +} diff --git a/Sources/DataCacheMacros/VersionedPropertyMacro.swift b/Sources/DataCacheMacros/VersionedPropertyMacro.swift new file mode 100644 index 0000000..9a73e33 --- /dev/null +++ b/Sources/DataCacheMacros/VersionedPropertyMacro.swift @@ -0,0 +1,64 @@ +// +// VersionedPropertyMacro.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 14.01.2025. +// + +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics +import Foundation + +public struct VersionedPropertyMacro: AccessorMacro { + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + guard + let ident = declaration.as(VariableDeclSyntax.self)?.bindings.first?.pattern.as(IdentifierPatternSyntax.self) else { + return [] + } + return [ + """ + @storageRestrictions(initializes: _\(raw: ident.identifier.text)) + init(newValue) { + self._\(raw: ident.identifier.text) = newValue + } + """, + """ + get { + self._\(raw: ident.identifier.text) + } + """, + """ + set { + if newValue != self._\(raw: ident.identifier.text) { + self._version.\(raw: ident.identifier.text) = self._version.\(raw: ident.identifier.text) &+ 1 + } + self._\(raw: ident.identifier.text) = newValue + } + """ + ] + } +} + +extension VersionedPropertyMacro: PeerMacro { + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + providingPeersOf declaration: some SwiftSyntax.DeclSyntaxProtocol, + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.DeclSyntax] { + guard + let varDecl = declaration.as(VariableDeclSyntax.self), + let binding = varDecl.bindings.first + else { + return [] + } + + return ["private var _\(binding)"] + } +} diff --git a/Sources/EnumIdentable/EnumIdentable.swift b/Sources/EnumIdentable/EnumIdentable.swift index 7de7f69..089621d 100644 --- a/Sources/EnumIdentable/EnumIdentable.swift +++ b/Sources/EnumIdentable/EnumIdentable.swift @@ -1,6 +1,9 @@ +import Foundation + /// A macro that produces code form a given enum to be Identifiable even if it has associated values. /// Macro generates a new nested enum CaseID that is Identifiable and Hashable. /// CaseID has all cases of the original enum, but ignores associated values which not contains an "id" string in the parameter name. /// The cases with associated values that contains "id" string in the parameter name are used to generate the rawValue of the CaseID. -@attached(member, names: arbitrary) +@attached(extension, conformances: Equatable, Identifiable, Hashable, names: named(id), named(hash), named(==)) +@attached(member, names: named(CaseID), named(caseId)) public macro EnumIdentable() = #externalMacro(module: "EnumIdentableMacros",type: "EnumIdentableMacro") diff --git a/Sources/EnumIdentableMacros/Diagnostics.swift b/Sources/EnumIdentableMacros/Diagnostics.swift new file mode 100644 index 0000000..9842252 --- /dev/null +++ b/Sources/EnumIdentableMacros/Diagnostics.swift @@ -0,0 +1,30 @@ +// +// Diagnostics.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 20.01.2025. +// + +import SwiftDiagnostics + +extension EnumIdentableMacro { + public enum Diagnostics: String, DiagnosticMessage { + + case mustBeEnum, mustHaveCases + + public var message: String { + switch self { + case .mustBeEnum: + return "`@EnumIdentableMacro` can only be applied to an `enum`" + case .mustHaveCases: + return "`@EnumIdentableMacro` can only be applied to an `enum` with `case` statements" + } + } + + public var diagnosticID: MessageID { + MessageID(domain: "EnumIdentableMacro", id: rawValue) + } + + public var severity: DiagnosticSeverity { .error } + } +} diff --git a/Sources/EnumIdentableMacros/EnumIdentableMacro.swift b/Sources/EnumIdentableMacros/EnumIdentableMacro.swift index 8234da0..be6b41f 100644 --- a/Sources/EnumIdentableMacros/EnumIdentableMacro.swift +++ b/Sources/EnumIdentableMacros/EnumIdentableMacro.swift @@ -5,46 +5,36 @@ // Created by Simon Sestak on 31/07/2024. // -import SwiftCompilerPlugin import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros import SwiftDiagnostics import Foundation -public struct EnumIdentableMacro: MemberMacro { - public static func expansion( - of node: SwiftSyntax.AttributeSyntax, - providingMembersOf declaration: Declaration, - in context: Context - ) throws -> [SwiftSyntax.DeclSyntax] where - Declaration : SwiftSyntax.DeclGroupSyntax, - Context : SwiftSyntaxMacros.MacroExpansionContext - { - - // Check if the declaration is an enum - guard let declaration = declaration.as(EnumDeclSyntax.self) else { - let enumError = Diagnostic(node: node._syntaxNode, message: Diagnostics.mustBeEnum) - context.diagnose(enumError) - return [] +public enum EnumIdentableMacro { + struct AttachedEnumCase { + struct Parameter { + let name: String + let type: String } + + let caseName: String + let parameters: [Parameter] + } - // Get all AST element which represent cases from the enum - guard let enumCases: [SyntaxProtocol] = declaration.memberBlock + static func getAttachedEnumCases(declaration enumDecl: EnumDeclSyntax) -> [SyntaxProtocol]? { + enumDecl.memberBlock .children(viewMode: .fixedUp).filter({ $0.kind == .memberDeclList }) .first? .children(viewMode: .fixedUp).filter({ $0.kind == SyntaxKind.memberDeclListItem }) .flatMap({ $0.children(viewMode: .fixedUp).filter({ $0.kind == .enumCaseDecl })}) .flatMap({ $0.children(viewMode: .fixedUp).filter({ $0.kind == .enumCaseElementList })}) .flatMap({ $0.children(viewMode: .fixedUp).filter({ $0.kind == .enumCaseElement })}) - else { - let enumError = Diagnostic(node: node._syntaxNode, message: Diagnostics.mustHaveCases) - context.diagnose(enumError) - return [] - } + } - // Get all cases names with their parameters - let caseIds: [(case: String, parameters: [(name: String, type: String)])] = enumCases.compactMap { enumCase in + /// - ToDo: Fix Reporting + static func parseAttachedEnumCases(enumCases: [SyntaxProtocol]) -> [AttachedEnumCase] { + enumCases.compactMap { enumCase -> AttachedEnumCase? in guard let firstToken = enumCase.firstToken(viewMode: .fixedUp) else { return nil } @@ -64,148 +54,17 @@ public struct EnumIdentableMacro: MemberMacro { } return (item.firstName, item.type) } - // Check if the case contains an parameter that contains "id" - let parameters: [(name: String, type: String)] = parametersTokens.compactMap { name, type in + // Check if the case contains an parameter that contains "id" + let parameters: [AttachedEnumCase.Parameter] = parametersTokens.compactMap { name, type in if case let .identifier(idName) = name?.tokenKind, idName.lowercased().contains("id") { - return (name: idName, type: "\(type)") - } - return (name: "_", type: "_") - } - return (id, parameters) - } - - // Check if the enum has any parsed cases - guard !caseIds.isEmpty else { - let enumError = Diagnostic(node: node._syntaxNode, message: Diagnostics.mustHaveCases) - context.diagnose(enumError) - return [] - } - - let casesContainsId = caseIds.contains { !$0.parameters.map(\.name).allSatisfy { $0 == "_" }} - - // If new enum hasn't associated values, we can use the String conforming for generating the rawValue - let enumDefinition = casesContainsId ? "enum CaseID" : "enum CaseID: String" - - let enumSyntax = try EnumDeclSyntax(.init(stringLiteral: enumDefinition)) { - for item in caseIds { - EnumCaseDeclSyntax{ - if case let parameters = item.parameters, !parameters.isEmpty, parameters.contains(where: { $0.name != "_" }) { - let parameters = parameters.compactMap { - if $0.name != "_" { - return "\($0.name): \($0.type)" - } - return nil - }.joined(separator: ", ") - EnumCaseElementSyntax(name: .identifier("\(item.case)(\(parameters))")) - } else { - EnumCaseElementSyntax(name: .identifier(item.case)) - } - } - } - if casesContainsId { - try VariableDeclSyntax("var rawValue: String") { - try SwitchExprSyntax("switch self") { - for item in caseIds { - if case let parameters = item.parameters, !parameters.isEmpty, parameters.contains(where: { $0.name != "_" }) { - let parameters = parameters.map(\.name).filter { $0 != "_" } - SwitchCaseSyntax(stringLiteral: - """ - case let .\(item.case)(\(parameters.joined(separator: ", "))): - "\(item.case)-\(parameters.map { "\\(\($0))" }.joined(separator: "-"))" - """ - ) - } else { - SwitchCaseSyntax(stringLiteral: - """ - case .\(item.case): - "\(item.case)" - """ - ) - } - } - } - } - } - } - let idAccessor = try VariableDeclSyntax("var caseId: CaseID") { - try SwitchExprSyntax("switch self") { - for item in caseIds { - if case let parameters = item.parameters, !parameters.isEmpty, parameters.contains(where: { $0.name != "_" }) { - let definitionParameters = parameters.compactMap { - if $0.name != "_" { - return "\($0.name): \($0.name)" - } - return nil - }.joined(separator: ", ") - SwitchCaseSyntax(stringLiteral: - """ - case let .\(item.case)(\(parameters.map(\.name).joined(separator: ", "))): - .\(item.case)(\(definitionParameters)) - """ - ) - } else { - SwitchCaseSyntax(stringLiteral: - """ - case .\(item.case): - .\(item.case) - """ - ) - } + return .init(name: idName, type: "\(type)") } + return .init(name: "_", type: "_") } + return .init(caseName: id, parameters: parameters) } - let identifierVariable = try VariableDeclSyntax("var id: String") { - """ - self.caseId.rawValue - """ - } - let hashableConformance = try FunctionDeclSyntax("func hash(into hasher: inout Hasher)") { - """ - hasher.combine(id) - """ - } - let comparableConformance = try FunctionDeclSyntax("static func == (lhs: Self, rhs: Self) -> Bool") { - """ - lhs.id == rhs.id - """ - } - - return [ - DeclSyntax(enumSyntax), - DeclSyntax(idAccessor), - DeclSyntax(identifierVariable), - DeclSyntax(hashableConformance), - DeclSyntax(comparableConformance) - ] - } - - public enum Diagnostics: String, DiagnosticMessage { - - case mustBeEnum, mustHaveCases - - public var message: String { - switch self { - case .mustBeEnum: - return "`@EnumIdentableMacro` can only be applied to an `enum`" - case .mustHaveCases: - return "`@EnumIdentableMacro` can only be applied to an `enum` with `case` statements" - } - } - - public var diagnosticID: MessageID { - MessageID(domain: "EnumIdentableMacro", id: rawValue) - } - - public var severity: DiagnosticSeverity { .error } } } - -@main -struct EnumIdentablePlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - EnumIdentableMacro.self, - ] -} diff --git a/Sources/EnumIdentableMacros/ExtensionExpansion.swift b/Sources/EnumIdentableMacros/ExtensionExpansion.swift new file mode 100644 index 0000000..f9f21ec --- /dev/null +++ b/Sources/EnumIdentableMacros/ExtensionExpansion.swift @@ -0,0 +1,69 @@ +// +// ExtensionMacro.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 20.01.2025. +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics +import Foundation + +extension EnumIdentableMacro: ExtensionMacro { + public static func expansion( + of node: SwiftSyntax.AttributeSyntax, + attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, + providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, + conformingTo protocols: [SwiftSyntax.TypeSyntax], + in context: some SwiftSyntaxMacros.MacroExpansionContext + ) throws -> [SwiftSyntax.ExtensionDeclSyntax] { + guard declaration.is(EnumDeclSyntax.self) else { + let enumError = Diagnostic(node: node._syntaxNode, message: Diagnostics.mustBeEnum) + context.diagnose(enumError) + return [] + } + + let protocolNames: Set = Set( + protocols.map { "\($0)".trimmingCharacters(in: .whitespaces)} + ) + + let equatableConformance = try ExtensionDeclSyntax( + """ + extension \(type): Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + } + """ + ) + + let hashableConformance = try ExtensionDeclSyntax( + """ + extension \(type): Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } + """ + ) + + let identifiableConformance = try ExtensionDeclSyntax( + """ + extension \(type): Identifiable { + var id: String { + self.caseId.rawValue + } + } + """ + ) + + + // There is probably bug in Swift Tests where conformances are not passed down. Should investigate. + return [] + + (protocolNames.contains("Equatable") ? [equatableConformance] : []) + + (protocolNames.contains("Hashable") ? [hashableConformance] : []) + + (protocolNames.contains("Identifiable") ? [identifiableConformance] : []) + } +} diff --git a/Sources/EnumIdentableMacros/MemberExpansion.swift b/Sources/EnumIdentableMacros/MemberExpansion.swift new file mode 100644 index 0000000..62db70a --- /dev/null +++ b/Sources/EnumIdentableMacros/MemberExpansion.swift @@ -0,0 +1,123 @@ +// +// MemberExpansion.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 20.01.2025. +// + +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftDiagnostics +import Foundation + +extension EnumIdentableMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + + // Check if the declaration is an enum + guard let declaration = declaration.as(EnumDeclSyntax.self) else { + let enumError = Diagnostic(node: node._syntaxNode, message: Diagnostics.mustBeEnum) + context.diagnose(enumError) + return [] + } + + // Get all AST element which represent cases from the enum + guard let enumCases = getAttachedEnumCases(declaration: declaration) else { + let enumError = Diagnostic(node: node._syntaxNode, message: Diagnostics.mustHaveCases) + context.diagnose(enumError) + return [] + } + + let caseIds = parseAttachedEnumCases(enumCases: enumCases) + + // Check if the enum has any parsed cases + guard !caseIds.isEmpty else { + let enumError = Diagnostic(node: node._syntaxNode, message: Diagnostics.mustHaveCases) + context.diagnose(enumError) + return [] + } + + let casesContainsId = caseIds.contains { !$0.parameters.map(\.name).allSatisfy { $0 == "_" }} + + // If new enum hasn't associated values, we can use the String conforming for generating the rawValue + let enumDefinition = casesContainsId ? "enum CaseID" : "enum CaseID: String" + + let enumSyntax = try EnumDeclSyntax(.init(stringLiteral: enumDefinition)) { + for item in caseIds { + EnumCaseDeclSyntax{ + if case let parameters = item.parameters, !parameters.isEmpty, parameters.contains(where: { $0.name != "_" }) { + let parameters = parameters.compactMap { + if $0.name != "_" { + return "\($0.name): \($0.type)" + } + return nil + }.joined(separator: ", ") + EnumCaseElementSyntax(name: .identifier("\(item.caseName)(\(parameters))")) + } else { + EnumCaseElementSyntax(name: .identifier(item.caseName)) + } + } + } + if casesContainsId { + try VariableDeclSyntax("var rawValue: String") { + try SwitchExprSyntax("switch self") { + for item in caseIds { + if case let parameters = item.parameters, !parameters.isEmpty, parameters.contains(where: { $0.name != "_" }) { + let parameters = parameters.map(\.name).filter { $0 != "_" } + SwitchCaseSyntax(stringLiteral: + """ + case let .\(item.caseName)(\(parameters.joined(separator: ", "))): + "\(item.caseName)-\(parameters.map { "\\(\($0))" }.joined(separator: "-"))" + """ + ) + } else { + SwitchCaseSyntax(stringLiteral: + """ + case .\(item.caseName): + "\(item.caseName)" + """ + ) + } + } + } + } + } + } + let idAccessor = try VariableDeclSyntax("var caseId: CaseID") { + try SwitchExprSyntax("switch self") { + for item in caseIds { + if case let parameters = item.parameters, !parameters.isEmpty, parameters.contains(where: { $0.name != "_" }) { + let definitionParameters = parameters.compactMap { + if $0.name != "_" { + return "\($0.name): \($0.name)" + } + return nil + }.joined(separator: ", ") + SwitchCaseSyntax(stringLiteral: + """ + case let .\(item.caseName)(\(parameters.map(\.name).joined(separator: ", "))): + .\(item.caseName)(\(definitionParameters)) + """ + ) + } else { + SwitchCaseSyntax(stringLiteral: + """ + case .\(item.caseName): + .\(item.caseName) + """ + ) + } + } + } + } + return [ + DeclSyntax(enumSyntax), + DeclSyntax(idAccessor) + ] + } + +} diff --git a/Sources/EnumIdentableMacros/Plugin.swift b/Sources/EnumIdentableMacros/Plugin.swift new file mode 100644 index 0000000..e645b83 --- /dev/null +++ b/Sources/EnumIdentableMacros/Plugin.swift @@ -0,0 +1,16 @@ +// +// Plugin.swift +// FuturedMacros +// +// Created by Mikoláš Stuchlík on 20.01.2025. +// + +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct EnumIdentablePlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + EnumIdentableMacro.self, + ] +} diff --git a/Tests/DataCacheTests/DataCacheTests.swift b/Tests/DataCacheTests/DataCacheTests.swift new file mode 100644 index 0000000..a4a582b --- /dev/null +++ b/Tests/DataCacheTests/DataCacheTests.swift @@ -0,0 +1,314 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +#if canImport(DataCacheMacros) +import DataCacheMacros + +let testMacros: [String: Macro.Type] = [ + "DataCache": DataCacheMacro.self, + "CacheSubscribe": CacheSubscriptionMacro.self, + "ProxySetter": ProxySetterMacro.self +] +let versionedPropertyMacros: [String: Macro.Type] = [ + "VersionedProperty": VersionedPropertyMacro.self, +] +#endif + +final class DataCacheTests: XCTestCase { + func testClassExpansion() throws { +#if canImport(DataCacheMacros) + assertMacroExpansion( + """ + @MainActor + @DataCache + final class Global { + var userName: String? + var revision: Int = 0 + + init(userName: String?) { + self.userName = userName + } + } + """ + , + expandedSource: + #""" + @MainActor + final class Global { + @VersionedProperty + var userName: String? + @VersionedProperty + var revision: Int = 0 + + init(userName: String?) { + self.userName = userName + } + + struct _Versions: Hashable { + var userName: UInt = 0 + var revision: UInt = 0 + } + + private var _version: _Versions = .init() + + private var _subscribtions: [SubscriptionBox] = [] + + func makeSubscriber(predicate: @escaping (_ oldValue: _Versions, _ newValue: _Versions) -> Bool) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Global.self) + let box = SubscriptionBox( + initialVersion: self._version, + continuation: continuation, + predicate: predicate + ) + + continuation.onTermination = { [weak self] _ in + self?._subscribtions.removeAll { + $0 === box + } + } + + self._subscribtions.append(box) + return stream + } + + func applyChanges(during block: () -> Void) { + block() + for subscribtion in _subscribtions { + subscribtion.emmit(cache: self, ifDiffers: self._version) + } + } + } + + extension Global: VersionedDataCache { + } + """# + , + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func testSubscriberExpansion() throws { +#if canImport(DataCacheMacros) + assertMacroExpansion( + #""" + #CacheSubscribe(on: global, properties: \.userName, \.revision) + """# + , + expandedSource: + #""" + global.makeSubscriber( + predicate: { + $0.userName != $1.userName || $0.revision != $1.revision + } + ) + """# + , + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + + func testVersionedProperty() throws { +#if canImport(DataCacheMacros) + assertMacroExpansion( + #""" + final class Global { + @VersionedProperty var userName: String? + @VersionedProperty var revision: Int = 0 + + init(userName: String?) { + self.userName = userName + } + } + """# + , + expandedSource: + #""" + final class Global { + var userName: String? { + @storageRestrictions(initializes: _userName) + init(newValue) { + self._userName = newValue + } + get { + self._userName + } + set { + if newValue != self._userName { + self._version.userName = self._version.userName &+ 1 + } + self._userName = newValue + } + } + + private var _userName: String? + var revision: Int { + @storageRestrictions(initializes: _revision) + init(newValue) { + self._revision = newValue + } + get { + self._revision + } + set { + if newValue != self._revision { + self._version.revision = self._version.revision &+ 1 + } + self._revision = newValue + } + } + + private var _revision: Int = 0 + + init(userName: String?) { + self.userName = userName + } + } + """# + , + macros: versionedPropertyMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func testProxyExpansion() throws { +#if canImport(DataCacheMacros) + assertMacroExpansion( + """ + @ProxySetter + final class Global { + var userName: String? + var revision: Int = 0 + + init(userName: String?) { + self.userName = userName + } + } + """ + , + expandedSource: + #""" + final class Global { + var userName: String? + var revision: Int = 0 + + init(userName: String?) { + self.userName = userName + } + } + + extension Global: ProxySettable { + final class Proxy: ProxyObject { + private var ref: Global + + init(ref: Global) { + self.ref = ref + } + + var userName: String? { + get { + ref.userName + } + set { + ref.userName = newValue + } + } + + var revision: Int { + get { + ref.revision + } + set { + ref.revision = newValue + } + } + } + } + """# + , + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + + func testProxyGlobalActor() throws { +#if canImport(DataCacheMacros) + assertMacroExpansion( + """ + @MainActor + @ProxySetter(isolation: MainActor.self) + @DataCache(isolation: MainActor.self) + final class Global { + var userName: String? + var revision: Int = 0 + + init(userName: String?) { + self.userName = userName + } + } + """ + , + expandedSource: + #""" + @MainActor + final class Global { + var userName: String? + var revision: Int = 0 + + init(userName: String?) { + self.userName = userName + } + } + + extension Global: ProxySettable { + @MainActor + final class Proxy: ProxyObject { + private var ref: Global + + init(ref: Global) { + self.ref = ref + } + + var userName: String? { + get { + ref.userName + } + set { + ref.userName = newValue + } + } + + var revision: Int { + get { + ref.revision + } + set { + ref.revision = newValue + } + } + } + } + """# + , + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + +} diff --git a/Tests/EnumIdentableTests/EnumIdentableTests.swift b/Tests/EnumIdentableTests/EnumIdentableTests.swift index 7f90c86..a260c5a 100644 --- a/Tests/EnumIdentableTests/EnumIdentableTests.swift +++ b/Tests/EnumIdentableTests/EnumIdentableTests.swift @@ -48,17 +48,23 @@ final class EnumIdentableTests: XCTestCase { .three } } - - var id: String { - self.caseId.rawValue + } + + extension TestEnum: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id } - + } + + extension TestEnum: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id + } + + extension TestEnum: Identifiable { + var id: String { + self.caseId.rawValue } } """# @@ -95,17 +101,23 @@ final class EnumIdentableTests: XCTestCase { .one } } - - var id: String { - self.caseId.rawValue + } + + extension TestEnum: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id } - + } + + extension TestEnum: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id + } + + extension TestEnum: Identifiable { + var id: String { + self.caseId.rawValue } } """# @@ -148,17 +160,23 @@ final class EnumIdentableTests: XCTestCase { .three } } - - var id: String { - self.caseId.rawValue + } + + extension TestEnum: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id } - + } + + extension TestEnum: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id + } + + extension TestEnum: Identifiable { + var id: String { + self.caseId.rawValue } } """# @@ -222,17 +240,23 @@ final class EnumIdentableTests: XCTestCase { .four(modelId: modelId) } } - - var id: String { - self.caseId.rawValue + } + + extension TestEnum: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id } - + } + + extension TestEnum: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id + } + + extension TestEnum: Identifiable { + var id: String { + self.caseId.rawValue } } """# @@ -249,14 +273,14 @@ final class EnumIdentableTests: XCTestCase { assertMacroExpansion( """ @EnumIdentable - enum Destination: Hashable, Identifiable { + enum Destination { case destination(id: Int, a: String) } """ , expandedSource: #""" - enum Destination: Hashable, Identifiable { + enum Destination { case destination(id: Int, a: String) enum CaseID { @@ -275,17 +299,23 @@ final class EnumIdentableTests: XCTestCase { .destination(id: id) } } - - var id: String { - self.caseId.rawValue + } + + extension Destination: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id } - + } + + extension Destination: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id + } + + extension Destination: Identifiable { + var id: String { + self.caseId.rawValue } } """# @@ -302,14 +332,14 @@ final class EnumIdentableTests: XCTestCase { assertMacroExpansion( """ @EnumIdentable - enum Destination: Hashable, Identifiable { + enum Destination { case destination(id1: Int, id2: String, a: String) } """ , expandedSource: #""" - enum Destination: Hashable, Identifiable { + enum Destination { case destination(id1: Int, id2: String, a: String) enum CaseID { @@ -328,17 +358,23 @@ final class EnumIdentableTests: XCTestCase { .destination(id1: id1, id2: id2) } } + } - var id: String { - self.caseId.rawValue + extension Destination: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id } + } + extension Destination: Hashable { func hash(into hasher: inout Hasher) { hasher.combine(id) } + } - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id + extension Destination: Identifiable { + var id: String { + self.caseId.rawValue } } """#