From 00ee8f44eade67139db9ba6e0fd6c484241b862c Mon Sep 17 00:00:00 2001 From: Christopher Fuller Date: Fri, 15 Mar 2024 14:14:32 -0700 Subject: [PATCH] Add Swift macro (requires Swift v5.9) (#58) * Add Swift macro (requires Swift v5.9) * Update Swift workflow * Fix expandable section in readme * Use initializers * Throw error instead of returning empty array * Do not abbreviate enumeration * Update file header comments * Fix tests * Utilize transform instead of for loop * Remove local variables * Improve formatting * Use throwing initializer --- .github/workflows/swift.yml | 14 +-- Package.resolved | 69 +++++++------ Package@swift-5.9.swift | 50 ++++++++++ README.md | 41 +++++--- Swift/Sources/StateMachine/Macros.swift | 10 ++ .../Macros/StateMachineHashableMacro.swift | 90 +++++++++++++++++ .../StateMachineHashableMacroError.swift | 19 ++++ .../StateMachineMacros.swift | 17 ++++ .../StateMachineMacrosTests.swift | 97 +++++++++++++++++++ .../StateMachineTests/StateMachineTests.swift | 10 +- .../StateMachine_Matter_Tests.swift | 10 +- .../StateMachine_Turnstile_Tests.swift | 70 +------------ 12 files changed, 371 insertions(+), 126 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/.github/workflows/swift.yml b/.github/workflows/swift.yml index 1f107d3..64df7de 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -7,14 +7,16 @@ on: branches: [ main ] env: - DEVELOPER_DIR: /Applications/Xcode_12.5.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer jobs: - build_and_test: - runs-on: macos-11 + swift: + name: Swift + runs-on: macos-13 steps: - - uses: actions/checkout@v2 + - name: Checkout source + uses: actions/checkout@v3 - name: Build - run: swift build -v - - name: Run tests + run: swift build -v -Xswiftc -warnings-as-errors + - name: Test run: swift test -v 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..01d3aa4 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,23 @@ expect(transition).to(equal( expect(logger).to(log(Message.melted)) ``` -### Swift Enumerations with Associated Values +#### Pre-Swift 5.9 Compatibility + +
+ +Expand -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. +This information is only applicable to Swift versions older than `5.9`: + +> ### 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 +244,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 +269,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..1861113 --- /dev/null +++ b/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacro.swift @@ -0,0 +1,90 @@ +// +// Copyright (c) 2019, Match Group, LLC +// BSD License, see LICENSE file for details +// + +import SwiftSyntax +import SwiftSyntaxBuilder +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 = .init(declaration) + else { throw StateMachineHashableMacroError.typeMustBeEnumeration } + + let elements: [EnumCaseElementSyntax] = enumDecl + .memberBlock + .members + .compactMap(MemberBlockItemSyntax.init) + .map(\.decl) + .compactMap(EnumCaseDeclSyntax.init) + .flatMap(\.elements) + + guard !elements.isEmpty + else { throw StateMachineHashableMacroError.enumerationMustHaveCases } + + let enumCases: [String] = elements + .map(\.name.text) + .map { "case \($0)" } + + let hashableIdentifierCases: [String] = elements + .map(\.name.text) + .map { "case .\($0):\nreturn .\($0)" } + + let associatedValueCases: [String] = elements.map { element in + if let parameters: EnumCaseParameterListSyntax = element.parameterClause?.parameters, !parameters.isEmpty { + if parameters.count > 1 { + let associatedValues: String = (1...parameters.count) + .map { "value\($0)" } + .joined(separator: ", ") + return """ + case let .\(element.name.text)(\(associatedValues)): + return (\(associatedValues)) + """ + } else { + return """ + case let .\(element.name.text)(value): + return (value) + """ + } + } else { + return """ + case .\(element.name.text): + return () + """ + } + } + + let node: SyntaxNodeString = """ + 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 try [ExtensionDeclSyntax(node)] + } +} diff --git a/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacroError.swift b/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacroError.swift new file mode 100644 index 0000000..f1e5917 --- /dev/null +++ b/Swift/Sources/StateMachineMacros/Macros/StateMachineHashableMacroError.swift @@ -0,0 +1,19 @@ +// +// Copyright (c) 2019, Match Group, LLC +// BSD License, see LICENSE file for details +// + +public enum StateMachineHashableMacroError: Error, CustomStringConvertible { + + case typeMustBeEnumeration + case enumerationMustHaveCases + + public var description: String { + switch self { + case .typeMustBeEnumeration: + return "Type Must Be Enumeration" + case .enumerationMustHaveCases: + return "Enumeration Must Have Cases" + } + } +} diff --git a/Swift/Sources/StateMachineMacros/StateMachineMacros.swift b/Swift/Sources/StateMachineMacros/StateMachineMacros.swift new file mode 100644 index 0000000..563ab1e --- /dev/null +++ b/Swift/Sources/StateMachineMacros/StateMachineMacros.swift @@ -0,0 +1,17 @@ +// +// Copyright (c) 2019, 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..3dc8aab --- /dev/null +++ b/Swift/Tests/StateMachineTests/StateMachineMacrosTests.swift @@ -0,0 +1,97 @@ +// +// Copyright (c) 2019, Match Group, LLC +// BSD License, see LICENSE file for details +// + +#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 Enumeration", line: 1, column: 1)], + macros: macros + ) + } + + func testEnumMustHaveCasesDiagnostic() { + assertMacroExpansion( + """ + @StateMachineHashable + enum Example {} + """, + expandedSource: """ + enum Example {} + """, + diagnostics: [DiagnosticSpec(message: "Enumeration 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..8e896ad 100644 --- a/Swift/Tests/StateMachineTests/StateMachineTests.swift +++ b/Swift/Tests/StateMachineTests/StateMachineTests.swift @@ -1,6 +1,6 @@ // -// Created by Christopher Fuller on 12/21/19. -// Copyright © 2019 Tinder. All rights reserved. +// Copyright (c) 2019, Match Group, LLC +// BSD License, see LICENSE file for details // import Nimble @@ -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..a660f4c 100644 --- a/Swift/Tests/StateMachineTests/StateMachine_Matter_Tests.swift +++ b/Swift/Tests/StateMachineTests/StateMachine_Matter_Tests.swift @@ -1,6 +1,6 @@ // -// Created by Christopher Fuller on 12/21/19. -// Copyright © 2019 Tinder. All rights reserved. +// Copyright (c) 2019, Match Group, LLC +// BSD License, see LICENSE file for details // import Nimble @@ -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..3498472 100644 --- a/Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift +++ b/Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift @@ -1,6 +1,6 @@ // -// Created by Christopher Fuller on 12/21/19. -// Copyright © 2019 Tinder. All rights reserved. +// Copyright (c) 2019, Match Group, LLC +// BSD License, see LICENSE file for details // import Nimble @@ -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 () - } - } -}