Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Swift macro (requires Swift v5.9) #58

Merged
merged 12 commits into from
Mar 15, 2024
14 changes: 8 additions & 6 deletions .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 [email protected]
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"),
]
)
41 changes: 27 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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> {
Expand Down Expand Up @@ -71,11 +71,11 @@ val stateMachine = StateMachine.create<State, Event, SideEffect> {
}
}
}
~~~
```

Perform state transitions:

~~~kotlin
```kotlin
assertThat(stateMachine.state).isEqualTo(Solid)

// When
Expand All @@ -87,7 +87,7 @@ assertThat(transition).isEqualTo(
StateMachine.Transition.Valid(Solid, OnMelted, Liquid, LogMelted)
)
then(logger).should().log(ON_MELTED_MESSAGE)
~~~
```

## Swift Usage

Expand All @@ -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
}

Expand Down Expand Up @@ -167,12 +169,23 @@ expect(transition).to(equal(
expect(logger).to(log(Message.melted))
```

### Swift Enumerations with Associated Values
#### Pre-Swift 5.9 Compatibility

<details>

<summary>Expand</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.
<br>

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.

</details>

## Examples

Expand Down Expand Up @@ -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.

Expand All @@ -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.
~~~
```
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,92 @@
//
// Copyright (c) 2019, 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 = .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: 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"))
}
}
}
"""

guard let extensionDecl: ExtensionDeclSyntax = .init(node)
else { throw StateMachineHashableMacroError.invalidExtension }

return [extensionDecl]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// Copyright (c) 2019, Match Group, LLC
// BSD License, see LICENSE file for details
//

public enum StateMachineHashableMacroError: Error, CustomStringConvertible {

case typeMustBeEnumeration
case enumerationMustHaveCases
case invalidExtension

public var description: String {
switch self {
case .typeMustBeEnumeration:
return "Type Must Be Enumeration"
case .enumerationMustHaveCases:
return "Enumeration Must Have Cases"
case .invalidExtension:
return "Invalid Extension"
}
}
}
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) 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
Loading
Loading