Skip to content

Commit

Permalink
Add Swift macro (requires Swift v5.9) (#58)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
tinder-cfuller authored Mar 15, 2024
1 parent 0f79642 commit 00ee8f4
Show file tree
Hide file tree
Showing 12 changed files with 371 additions and 126 deletions.
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,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)]
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
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

0 comments on commit 00ee8f4

Please sign in to comment.