From 74a003f8ac9f0f6d9907ddb973e9218453348bcc Mon Sep 17 00:00:00 2001 From: Christopher Fuller Date: Wed, 28 Feb 2024 06:41:50 -0800 Subject: [PATCH] Add Swift macro (requires Swift v5.9) --- Package.resolved | 69 +++++++------ Package@swift-5.9.swift | 50 ++++++++++ README.md | 37 ++++--- Swift/Sources/StateMachine/Macros.swift | 10 ++ .../Macros/StateMachineHashableMacro.swift | 93 ++++++++++++++++++ .../StateMachineHashableMacroError.swift | 19 ++++ .../StateMachineMacros.swift | 17 ++++ .../StateMachineMacrosTests.swift | 97 +++++++++++++++++++ .../StateMachineTests/StateMachineTests.swift | 6 +- .../StateMachine_Matter_Tests.swift | 6 +- .../StateMachine_Turnstile_Tests.swift | 66 +------------ 11 files changed, 356 insertions(+), 114 deletions(-) create mode 100644 Package@swift-5.9.swift create mode 100644 Swift/Sources/StateMachine/Macros.swift create mode 100644 Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacro.swift create mode 100644 Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacroError.swift create mode 100644 Swift/Sources/StateMachineMacros/StateMachineMacros.swift create mode 100644 Swift/Tests/StateMachineTests/StateMachineMacrosTests.swift diff --git a/Package.resolved b/Package.resolved index 917f026..aec8385 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,34 +1,41 @@ { - "object": { - "pins": [ - { - "package": "CwlCatchException", - "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", - "state": { - "branch": null, - "revision": "682841464136f8c66e04afe5dbd01ab51a3a56f2", - "version": "2.1.0" - } - }, - { - "package": "CwlPreconditionTesting", - "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state": { - "branch": null, - "revision": "02b7a39a99c4da27abe03cab2053a9034379639f", - "version": "2.0.0" - } - }, - { - "package": "Nimble", - "repositoryURL": "https://github.com/Quick/Nimble.git", - "state": { - "branch": null, - "revision": "af1730dde4e6c0d45bf01b99f8a41713ce536790", - "version": "9.2.0" - } + "pins" : [ + { + "identity" : "cwlcatchexception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "state" : { + "revision" : "3b123999de19bf04905bc1dfdb76f817b0f2cc00", + "version" : "2.1.2" } - ] - }, - "version": 1 + }, + { + "identity" : "cwlpreconditiontesting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state" : { + "revision" : "dc9af4781f2afdd1e68e90f80b8603be73ea7abc", + "version" : "2.2.0" + } + }, + { + "identity" : "nimble", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Quick/Nimble.git", + "state" : { + "revision" : "efe11bbca024b57115260709b5c05e01131470d0", + "version" : "13.2.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 2 } diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000..8dd7734 --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,50 @@ +// swift-tools-version:5.9 + +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "StateMachine", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v5), + ], + products: [ + .library( + name: "StateMachine", + targets: ["StateMachine"]), + ], + dependencies: [ + .package( + url: "https://github.com/apple/swift-syntax.git", + from: "509.1.0"), + .package( + url: "https://github.com/Quick/Nimble.git", + from: "13.2.0"), + ], + targets: [ + .target( + name: "StateMachine", + dependencies: ["StateMachineMacros"], + path: "Swift/Sources/StateMachine"), + .macro( + name: "StateMachineMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + path: "Swift/Sources/StateMachineMacros"), + .testTarget( + name: "StateMachineTests", + dependencies: [ + "StateMachine", + "StateMachineMacros", + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + "Nimble", + ], + path: "Swift/Tests/StateMachineTests"), + ] +) diff --git a/README.md b/README.md index 4cd1e87..709f072 100755 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The examples below create a `StateMachine` from the following state diagram for Define states, events and side effects: -~~~kotlin +```kotlin sealed class State { object Solid : State() object Liquid : State() @@ -36,11 +36,11 @@ sealed class SideEffect { object LogVaporized : SideEffect() object LogCondensed : SideEffect() } -~~~ +``` Initialize state machine and declare state transitions: -~~~kotlin +```kotlin val stateMachine = StateMachine.create { initialState(State.Solid) state { @@ -71,11 +71,11 @@ val stateMachine = StateMachine.create { } } } -~~~ +``` Perform state transitions: -~~~kotlin +```kotlin assertThat(stateMachine.state).isEqualTo(Solid) // When @@ -87,7 +87,7 @@ assertThat(transition).isEqualTo( StateMachine.Transition.Valid(Solid, OnMelted, Liquid, LogMelted) ) then(logger).should().log(ON_MELTED_MESSAGE) -~~~ +``` ## Swift Usage @@ -103,11 +103,13 @@ class MyExample: StateMachineBuilder { Define states, events and side effects: ```swift -enum State: StateMachineHashable { +@StateMachineHashable +enum State { case solid, liquid, gas } -enum Event: StateMachineHashable { +@StateMachineHashable +enum Event { case melt, freeze, vaporize, condense } @@ -167,12 +169,19 @@ expect(transition).to(equal( expect(logger).to(log(Message.melted)) ``` -### Swift Enumerations with Associated Values +
+ +

Pre-Swift 5.9 Compatibility

-Due to Swift enumerations (as opposed to sealed classes in Kotlin), -any `State` or `Event` enumeration defined with associated values will require [boilerplate implementation](https://github.com/Tinder/StateMachine/blob/c5c8155d55db5799190d9a06fbc31263c76c80b6/Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift#L198-L260) for `StateMachineHashable` conformance. +This information is only applicable to Swift versions older than `5.9`: -The easiest way to create this boilerplate is by using the [Sourcery](https://github.com/krzysztofzablocki/Sourcery) Swift code generator along with the [AutoStateMachineHashable stencil template](https://github.com/Tinder/StateMachine/blob/main/Swift/Resources/AutoStateMachineHashable.stencil) provided in this repository. Once the codegen is setup and configured, adopt `AutoStateMachineHashable` instead of `StateMachineHashable` for the `State` and/or `Event` enumerations. +> ### Swift Enumerations with Associated Values +> +> Due to Swift enumerations (as opposed to sealed classes in Kotlin), any `State` or `Event` enumeration defined with associated values will require [boilerplate implementation](https://github.com/Tinder/StateMachine/blob/c5c8155d55db5799190d9a06fbc31263c76c80b6/Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift#L198-L260) for `StateMachineHashable` conformance. +> +> The easiest way to create this boilerplate is by using the [Sourcery](https://github.com/krzysztofzablocki/Sourcery) Swift code generator along with the [AutoStateMachineHashable stencil template](https://github.com/Tinder/StateMachine/blob/main/Swift/Resources/AutoStateMachineHashable.stencil) provided in this repository. Once the codegen is setup and configured, adopt `AutoStateMachineHashable` instead of `StateMachineHashable` for the `State` and/or `Event` enumerations. + +
## Examples @@ -231,7 +240,7 @@ pod 'StateMachine', :git => 'https://github.com/Tinder/StateMachine.git' Thanks to [@nvinayshetty](https://github.com/nvinayshetty), you can visualize your state machines right in the IDE using the [State Arts](https://github.com/nvinayshetty/StateArts) Intellij [plugin](https://plugins.jetbrains.com/plugin/12193-state-art). ## License -~~~ +``` Copyright (c) 2018, Match Group, LLC All rights reserved. @@ -256,4 +265,4 @@ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -~~~ +``` diff --git a/Swift/Sources/StateMachine/Macros.swift b/Swift/Sources/StateMachine/Macros.swift new file mode 100644 index 0000000..4ddbf48 --- /dev/null +++ b/Swift/Sources/StateMachine/Macros.swift @@ -0,0 +1,10 @@ +// +// Copyright (c) 2024, Match Group, LLC +// BSD License, see LICENSE file for details +// + +@attached(extension, + conformances: StateMachineHashable, + names: named(hashableIdentifier), named(HashableIdentifier), named(associatedValue)) +public macro StateMachineHashable() = #externalMacro(module: "StateMachineMacros", + type: "StateMachineHashableMacro") diff --git a/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacro.swift b/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacro.swift new file mode 100644 index 0000000..079c2ba --- /dev/null +++ b/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacro.swift @@ -0,0 +1,93 @@ +// +// Copyright (c) 2024, Match Group, LLC +// BSD License, see LICENSE file for details +// + +import SwiftSyntax +import SwiftSyntaxMacros + +public struct StateMachineHashableMacro: ExtensionMacro { + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + + guard let enumDecl: EnumDeclSyntax = declaration.as(EnumDeclSyntax.self) + else { throw StateMachineHashableMacroError.typeMustBeEnum } + + let elements: [EnumCaseElementSyntax] = enumDecl + .memberBlock + .members + .compactMap { $0.as(MemberBlockItemSyntax.self) } + .map(\.decl) + .compactMap { $0.as(EnumCaseDeclSyntax.self) } + .flatMap(\.elements) + + guard !elements.isEmpty + else { throw StateMachineHashableMacroError.enumMustHaveCases } + + let enumCases: [String] = elements + .map(\.name.text) + .map { "case \($0)" } + + let hashableIdentifierCases: [String] = elements + .map(\.name.text) + .map { "case .\($0):\nreturn .\($0)" } + + var associatedValueCases: [String] = [] + for element: EnumCaseElementSyntax in elements { + if let parameters: EnumCaseParameterListSyntax = element.parameterClause?.parameters, !parameters.isEmpty { + if parameters.count > 1 { + let associatedValues: String = (1...parameters.count) + .map { "value\($0)" } + .joined(separator: ", ") + let `case`: String = """ + case let .\(element.name.text)(\(associatedValues)): + return (\(associatedValues)) + """ + associatedValueCases.append(`case`) + } else { + let `case`: String = """ + case let .\(element.name.text)(value): + return (value) + """ + associatedValueCases.append(`case`) + } + } else { + let `case`: String = """ + case .\(element.name.text): + return () + """ + associatedValueCases.append(`case`) + } + } + + let decl: DeclSyntax = """ + extension \(type): StateMachineHashable { + + enum HashableIdentifier { + + \(raw: enumCases.joined(separator: "\n")) + } + + var hashableIdentifier: HashableIdentifier { + switch self { + \(raw: hashableIdentifierCases.joined(separator: "\n")) + } + } + + var associatedValue: Any { + switch self { + \(raw: associatedValueCases.joined(separator: "\n")) + } + } + } + """ + + return decl.as(ExtensionDeclSyntax.self).flatMap { [$0] } ?? [] + } +} diff --git a/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacroError.swift b/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacroError.swift new file mode 100644 index 0000000..612935c --- /dev/null +++ b/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacroError.swift @@ -0,0 +1,19 @@ +// +// Copyright (c) 2024, Match Group, LLC +// BSD License, see LICENSE file for details +// + +public enum StateMachineHashableMacroError: Error, CustomStringConvertible { + + case typeMustBeEnum + case enumMustHaveCases + + public var description: String { + switch self { + case .typeMustBeEnum: + return "Type Must Be Enum" + case .enumMustHaveCases: + return "Enum Must Have Cases" + } + } +} diff --git a/Swift/Sources/StateMachineMacros/StateMachineMacros.swift b/Swift/Sources/StateMachineMacros/StateMachineMacros.swift new file mode 100644 index 0000000..b46bafe --- /dev/null +++ b/Swift/Sources/StateMachineMacros/StateMachineMacros.swift @@ -0,0 +1,17 @@ +// +// Copyright (c) 2024, Match Group, LLC +// BSD License, see LICENSE file for details +// + +#if canImport(SwiftCompilerPlugin) + +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +internal struct StateMachineMacros: CompilerPlugin { + + internal let providingMacros: [Macro.Type] = [StateMachineHashableMacro.self] +} + +#endif diff --git a/Swift/Tests/StateMachineTests/StateMachineMacrosTests.swift b/Swift/Tests/StateMachineTests/StateMachineMacrosTests.swift new file mode 100644 index 0000000..a19b300 --- /dev/null +++ b/Swift/Tests/StateMachineTests/StateMachineMacrosTests.swift @@ -0,0 +1,97 @@ +// +// Created by Christopher Fuller on 12/21/19. +// Copyright © 2019 Tinder. All rights reserved. +// + +#if canImport(StateMachineMacros) + +import StateMachineMacros +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +final class StateMachineMacrosTests: XCTestCase { + + private let macros: [String: Macro.Type] = ["StateMachineHashable": StateMachineHashableMacro.self] + + func testTypeMustBeEnumDiagnostic() { + assertMacroExpansion( + """ + @StateMachineHashable + struct Example {} + """, + expandedSource: """ + struct Example {} + """, + diagnostics: [DiagnosticSpec(message: "Type Must Be Enum", line: 1, column: 1)], + macros: macros + ) + } + + func testEnumMustHaveCasesDiagnostic() { + assertMacroExpansion( + """ + @StateMachineHashable + enum Example {} + """, + expandedSource: """ + enum Example {} + """, + diagnostics: [DiagnosticSpec(message: "Enum Must Have Cases", line: 1, column: 1)], + macros: macros + ) + } + + func testStateMachineHashableMacro() { + assertMacroExpansion( + """ + @StateMachineHashable + enum Example { + + case case0, case1(Any), case2(Any, Any) + } + """, + expandedSource: """ + enum Example { + + case case0, case1(Any), case2(Any, Any) + } + + extension Example: StateMachineHashable { + + enum HashableIdentifier { + + case case0 + case case1 + case case2 + } + + var hashableIdentifier: HashableIdentifier { + switch self { + case .case0: + return .case0 + case .case1: + return .case1 + case .case2: + return .case2 + } + } + + var associatedValue: Any { + switch self { + case .case0: + return () + case let .case1(value): + return (value) + case let .case2(value1, value2): + return (value1, value2) + } + } + } + """, + macros: macros + ) + } +} + +#endif diff --git a/Swift/Tests/StateMachineTests/StateMachineTests.swift b/Swift/Tests/StateMachineTests/StateMachineTests.swift index 9813b34..8003901 100644 --- a/Swift/Tests/StateMachineTests/StateMachineTests.swift +++ b/Swift/Tests/StateMachineTests/StateMachineTests.swift @@ -202,13 +202,13 @@ final class Logger { } } -func log(_ expectedMessages: String...) -> Predicate { +func log(_ expectedMessages: String...) -> Matcher { let expectedString: String = stringify(expectedMessages.joined(separator: "\\n")) - return Predicate { + return Matcher { let actualMessages: [String]? = try $0.evaluate()?.messages let actualString: String = stringify(actualMessages?.joined(separator: "\\n")) let message: ExpectationMessage = .expectedCustomValueTo("log <\(expectedString)>", actual: "<\(actualString)>") - return PredicateResult(bool: actualMessages == expectedMessages, message: message) + return MatcherResult(bool: actualMessages == expectedMessages, message: message) } } diff --git a/Swift/Tests/StateMachineTests/StateMachine_Matter_Tests.swift b/Swift/Tests/StateMachineTests/StateMachine_Matter_Tests.swift index d2d926e..1be563f 100644 --- a/Swift/Tests/StateMachineTests/StateMachine_Matter_Tests.swift +++ b/Swift/Tests/StateMachineTests/StateMachine_Matter_Tests.swift @@ -9,12 +9,14 @@ import XCTest final class StateMachine_Matter_Tests: XCTestCase, StateMachineBuilder { - enum State: StateMachineHashable { + @StateMachineHashable + enum State { case solid, liquid, gas } - enum Event: StateMachineHashable { + @StateMachineHashable + enum Event { case melt, freeze, vaporize, condense } diff --git a/Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift b/Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift index 38fbf33..1465d6e 100644 --- a/Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift +++ b/Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift @@ -14,11 +14,13 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder { static let farePrice: Int = 50 } + @StateMachineHashable indirect enum State: Equatable { case locked(credit: Int), unlocked, broken(oldState: State) } + @StateMachineHashable enum Event: Equatable { case insertCoin(Int), admitPerson, machineDidFail, machineRepairDidComplete @@ -194,67 +196,3 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder { sideEffect: nil))) } } - -extension StateMachine_Turnstile_Tests.State: StateMachineHashable { - - enum HashableIdentifier { - - case locked, unlocked, broken - } - - var hashableIdentifier: HashableIdentifier { - switch self { - case .locked: - return .locked - case .unlocked: - return .unlocked - case .broken: - return .broken - } - } - - var associatedValue: Any { - switch self { - case let .locked(credit): - return credit - case .unlocked: - return () - case let .broken(oldState): - return oldState - } - } -} - -extension StateMachine_Turnstile_Tests.Event: StateMachineHashable { - - enum HashableIdentifier { - - case insertCoin, admitPerson, machineDidFail, machineRepairDidComplete - } - - var hashableIdentifier: HashableIdentifier { - switch self { - case .insertCoin: - return .insertCoin - case .admitPerson: - return .admitPerson - case .machineDidFail: - return .machineDidFail - case .machineRepairDidComplete: - return .machineRepairDidComplete - } - } - - var associatedValue: Any { - switch self { - case let .insertCoin(value): - return value - case .admitPerson: - return () - case .machineDidFail: - return () - case .machineRepairDidComplete: - return () - } - } -}