Skip to content

Commit 00ee8f4

Browse files
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
1 parent 0f79642 commit 00ee8f4

12 files changed

+371
-126
lines changed

.github/workflows/swift.yml

+8-6
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ on:
77
branches: [ main ]
88

99
env:
10-
DEVELOPER_DIR: /Applications/Xcode_12.5.1.app/Contents/Developer
10+
DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer
1111

1212
jobs:
13-
build_and_test:
14-
runs-on: macos-11
13+
swift:
14+
name: Swift
15+
runs-on: macos-13
1516
steps:
16-
- uses: actions/checkout@v2
17+
- name: Checkout source
18+
uses: actions/checkout@v3
1719
- name: Build
18-
run: swift build -v
19-
- name: Run tests
20+
run: swift build -v -Xswiftc -warnings-as-errors
21+
- name: Test
2022
run: swift test -v

Package.resolved

+38-31
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,41 @@
11
{
2-
"object": {
3-
"pins": [
4-
{
5-
"package": "CwlCatchException",
6-
"repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git",
7-
"state": {
8-
"branch": null,
9-
"revision": "682841464136f8c66e04afe5dbd01ab51a3a56f2",
10-
"version": "2.1.0"
11-
}
12-
},
13-
{
14-
"package": "CwlPreconditionTesting",
15-
"repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git",
16-
"state": {
17-
"branch": null,
18-
"revision": "02b7a39a99c4da27abe03cab2053a9034379639f",
19-
"version": "2.0.0"
20-
}
21-
},
22-
{
23-
"package": "Nimble",
24-
"repositoryURL": "https://github.com/Quick/Nimble.git",
25-
"state": {
26-
"branch": null,
27-
"revision": "af1730dde4e6c0d45bf01b99f8a41713ce536790",
28-
"version": "9.2.0"
29-
}
2+
"pins" : [
3+
{
4+
"identity" : "cwlcatchexception",
5+
"kind" : "remoteSourceControl",
6+
"location" : "https://github.com/mattgallagher/CwlCatchException.git",
7+
"state" : {
8+
"revision" : "3b123999de19bf04905bc1dfdb76f817b0f2cc00",
9+
"version" : "2.1.2"
3010
}
31-
]
32-
},
33-
"version": 1
11+
},
12+
{
13+
"identity" : "cwlpreconditiontesting",
14+
"kind" : "remoteSourceControl",
15+
"location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git",
16+
"state" : {
17+
"revision" : "dc9af4781f2afdd1e68e90f80b8603be73ea7abc",
18+
"version" : "2.2.0"
19+
}
20+
},
21+
{
22+
"identity" : "nimble",
23+
"kind" : "remoteSourceControl",
24+
"location" : "https://github.com/Quick/Nimble.git",
25+
"state" : {
26+
"revision" : "efe11bbca024b57115260709b5c05e01131470d0",
27+
"version" : "13.2.1"
28+
}
29+
},
30+
{
31+
"identity" : "swift-syntax",
32+
"kind" : "remoteSourceControl",
33+
"location" : "https://github.com/apple/swift-syntax.git",
34+
"state" : {
35+
"revision" : "64889f0c732f210a935a0ad7cda38f77f876262d",
36+
"version" : "509.1.1"
37+
}
38+
}
39+
],
40+
"version" : 2
3441
}

[email protected]

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// swift-tools-version:5.9
2+
3+
import PackageDescription
4+
import CompilerPluginSupport
5+
6+
let package = Package(
7+
name: "StateMachine",
8+
platforms: [
9+
.macOS(.v10_15),
10+
.iOS(.v13),
11+
.tvOS(.v13),
12+
.watchOS(.v5),
13+
],
14+
products: [
15+
.library(
16+
name: "StateMachine",
17+
targets: ["StateMachine"]),
18+
],
19+
dependencies: [
20+
.package(
21+
url: "https://github.com/apple/swift-syntax.git",
22+
from: "509.1.0"),
23+
.package(
24+
url: "https://github.com/Quick/Nimble.git",
25+
from: "13.2.0"),
26+
],
27+
targets: [
28+
.target(
29+
name: "StateMachine",
30+
dependencies: ["StateMachineMacros"],
31+
path: "Swift/Sources/StateMachine"),
32+
.macro(
33+
name: "StateMachineMacros",
34+
dependencies: [
35+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
36+
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
37+
],
38+
path: "Swift/Sources/StateMachineMacros"),
39+
.testTarget(
40+
name: "StateMachineTests",
41+
dependencies: [
42+
"StateMachine",
43+
"StateMachineMacros",
44+
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
45+
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
46+
"Nimble",
47+
],
48+
path: "Swift/Tests/StateMachineTests"),
49+
]
50+
)

README.md

+27-14
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The examples below create a `StateMachine` from the following state diagram for
1616

1717
Define states, events and side effects:
1818

19-
~~~kotlin
19+
```kotlin
2020
sealed class State {
2121
object Solid : State()
2222
object Liquid : State()
@@ -36,11 +36,11 @@ sealed class SideEffect {
3636
object LogVaporized : SideEffect()
3737
object LogCondensed : SideEffect()
3838
}
39-
~~~
39+
```
4040

4141
Initialize state machine and declare state transitions:
4242

43-
~~~kotlin
43+
```kotlin
4444
val stateMachine = StateMachine.create<State, Event, SideEffect> {
4545
initialState(State.Solid)
4646
state<State.Solid> {
@@ -71,11 +71,11 @@ val stateMachine = StateMachine.create<State, Event, SideEffect> {
7171
}
7272
}
7373
}
74-
~~~
74+
```
7575

7676
Perform state transitions:
7777

78-
~~~kotlin
78+
```kotlin
7979
assertThat(stateMachine.state).isEqualTo(Solid)
8080

8181
// When
@@ -87,7 +87,7 @@ assertThat(transition).isEqualTo(
8787
StateMachine.Transition.Valid(Solid, OnMelted, Liquid, LogMelted)
8888
)
8989
then(logger).should().log(ON_MELTED_MESSAGE)
90-
~~~
90+
```
9191

9292
## Swift Usage
9393

@@ -103,11 +103,13 @@ class MyExample: StateMachineBuilder {
103103
Define states, events and side effects:
104104

105105
```swift
106-
enum State: StateMachineHashable {
106+
@StateMachineHashable
107+
enum State {
107108
case solid, liquid, gas
108109
}
109110

110-
enum Event: StateMachineHashable {
111+
@StateMachineHashable
112+
enum Event {
111113
case melt, freeze, vaporize, condense
112114
}
113115

@@ -167,12 +169,23 @@ expect(transition).to(equal(
167169
expect(logger).to(log(Message.melted))
168170
```
169171

170-
### Swift Enumerations with Associated Values
172+
#### Pre-Swift 5.9 Compatibility
173+
174+
<details>
175+
176+
<summary>Expand</summary>
171177

172-
Due to Swift enumerations (as opposed to sealed classes in Kotlin),
173-
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.
178+
<br>
174179

175-
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.
180+
This information is only applicable to Swift versions older than `5.9`:
181+
182+
> ### Swift Enumerations with Associated Values
183+
>
184+
> 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.
185+
>
186+
> 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.
187+
188+
</details>
176189

177190
## Examples
178191

@@ -231,7 +244,7 @@ pod 'StateMachine', :git => 'https://github.com/Tinder/StateMachine.git'
231244
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).
232245

233246
## License
234-
~~~
247+
```
235248
Copyright (c) 2018, Match Group, LLC
236249
All rights reserved.
237250
@@ -256,4 +269,4 @@ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
256269
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
257270
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
258271
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
259-
~~~
272+
```
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//
2+
// Copyright (c) 2024, Match Group, LLC
3+
// BSD License, see LICENSE file for details
4+
//
5+
6+
@attached(extension,
7+
conformances: StateMachineHashable,
8+
names: named(hashableIdentifier), named(HashableIdentifier), named(associatedValue))
9+
public macro StateMachineHashable() = #externalMacro(module: "StateMachineMacros",
10+
type: "StateMachineHashableMacro")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// Copyright (c) 2019, Match Group, LLC
3+
// BSD License, see LICENSE file for details
4+
//
5+
6+
import SwiftSyntax
7+
import SwiftSyntaxBuilder
8+
import SwiftSyntaxMacros
9+
10+
public struct StateMachineHashableMacro: ExtensionMacro {
11+
12+
public static func expansion(
13+
of node: AttributeSyntax,
14+
attachedTo declaration: some DeclGroupSyntax,
15+
providingExtensionsOf type: some TypeSyntaxProtocol,
16+
conformingTo protocols: [TypeSyntax],
17+
in context: some MacroExpansionContext
18+
) throws -> [ExtensionDeclSyntax] {
19+
20+
guard let enumDecl: EnumDeclSyntax = .init(declaration)
21+
else { throw StateMachineHashableMacroError.typeMustBeEnumeration }
22+
23+
let elements: [EnumCaseElementSyntax] = enumDecl
24+
.memberBlock
25+
.members
26+
.compactMap(MemberBlockItemSyntax.init)
27+
.map(\.decl)
28+
.compactMap(EnumCaseDeclSyntax.init)
29+
.flatMap(\.elements)
30+
31+
guard !elements.isEmpty
32+
else { throw StateMachineHashableMacroError.enumerationMustHaveCases }
33+
34+
let enumCases: [String] = elements
35+
.map(\.name.text)
36+
.map { "case \($0)" }
37+
38+
let hashableIdentifierCases: [String] = elements
39+
.map(\.name.text)
40+
.map { "case .\($0):\nreturn .\($0)" }
41+
42+
let associatedValueCases: [String] = elements.map { element in
43+
if let parameters: EnumCaseParameterListSyntax = element.parameterClause?.parameters, !parameters.isEmpty {
44+
if parameters.count > 1 {
45+
let associatedValues: String = (1...parameters.count)
46+
.map { "value\($0)" }
47+
.joined(separator: ", ")
48+
return """
49+
case let .\(element.name.text)(\(associatedValues)):
50+
return (\(associatedValues))
51+
"""
52+
} else {
53+
return """
54+
case let .\(element.name.text)(value):
55+
return (value)
56+
"""
57+
}
58+
} else {
59+
return """
60+
case .\(element.name.text):
61+
return ()
62+
"""
63+
}
64+
}
65+
66+
let node: SyntaxNodeString = """
67+
extension \(type): StateMachineHashable {
68+
69+
enum HashableIdentifier {
70+
71+
\(raw: enumCases.joined(separator: "\n"))
72+
}
73+
74+
var hashableIdentifier: HashableIdentifier {
75+
switch self {
76+
\(raw: hashableIdentifierCases.joined(separator: "\n"))
77+
}
78+
}
79+
80+
var associatedValue: Any {
81+
switch self {
82+
\(raw: associatedValueCases.joined(separator: "\n"))
83+
}
84+
}
85+
}
86+
"""
87+
88+
return try [ExtensionDeclSyntax(node)]
89+
}
90+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//
2+
// Copyright (c) 2019, Match Group, LLC
3+
// BSD License, see LICENSE file for details
4+
//
5+
6+
public enum StateMachineHashableMacroError: Error, CustomStringConvertible {
7+
8+
case typeMustBeEnumeration
9+
case enumerationMustHaveCases
10+
11+
public var description: String {
12+
switch self {
13+
case .typeMustBeEnumeration:
14+
return "Type Must Be Enumeration"
15+
case .enumerationMustHaveCases:
16+
return "Enumeration Must Have Cases"
17+
}
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// Copyright (c) 2019, Match Group, LLC
3+
// BSD License, see LICENSE file for details
4+
//
5+
6+
#if canImport(SwiftCompilerPlugin)
7+
8+
import SwiftCompilerPlugin
9+
import SwiftSyntaxMacros
10+
11+
@main
12+
internal struct StateMachineMacros: CompilerPlugin {
13+
14+
internal let providingMacros: [Macro.Type] = [StateMachineHashableMacro.self]
15+
}
16+
17+
#endif

0 commit comments

Comments
 (0)