Skip to content

Commit

Permalink
Add Swift macro (requires Swift v5.9)
Browse files Browse the repository at this point in the history
  • Loading branch information
tinder-cfuller committed Feb 28, 2024
1 parent 0f79642 commit 74a003f
Showing 11 changed files with 356 additions and 114 deletions.
69 changes: 38 additions & 31 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -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
}
50 changes: 50 additions & 0 deletions Package@swift-5.9.swift
Original file line number Diff line number Diff line change
@@ -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"),
]
)
37 changes: 23 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -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<State, Event, SideEffect> {
initialState(State.Solid)
state<State.Solid> {
@@ -71,11 +71,11 @@ val stateMachine = StateMachine.create<State, Event, SideEffect> {
}
}
}
~~~
```

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
<details>

<summary><h4>Pre-Swift 5.9 Compatibility</h4></summary>

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.
</details>

## 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.
~~~
```
10 changes: 10 additions & 0 deletions Swift/Sources/StateMachine/Macros.swift
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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] } ?? []
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
17 changes: 17 additions & 0 deletions Swift/Sources/StateMachineMacros/StateMachineMacros.swift
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 74a003f

Please sign in to comment.