diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..839fec7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,18 @@
+name: Build and test
+
+on: [push]
+
+jobs:
+ build:
+
+ runs-on: macos-latest
+
+ steps:
+ - uses: swift-actions/setup-swift@v1
+ with:
+ swift-version: "5.6.1"
+ - uses: actions/checkout@v3
+ - name: Build
+ run: swift build
+ - name: Run tests
+ run: swift test
diff --git a/.gitignore b/.gitignore
index 330d167..72baa45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+.DS_Store
## User settings
xcuserdata/
diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AsyncStateMachine.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AsyncStateMachine.xcscheme
new file mode 100644
index 0000000..a262796
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/AsyncStateMachine.xcscheme
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Elevator.jpeg b/Elevator.jpeg
new file mode 100644
index 0000000..e4e7622
Binary files /dev/null and b/Elevator.jpeg differ
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..a8619a2
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins" : [
+ {
+ "identity" : "xctest-dynamic-overlay",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay.git",
+ "state" : {
+ "revision" : "ef8e14e7ce1c0c304c644c6ba365d06c468ded6b",
+ "version" : "0.3.3"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..5ea20d2
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,34 @@
+// swift-tools-version:5.6
+
+import PackageDescription
+
+let package = Package(
+ name: "AsyncStateMachine",
+ platforms: [
+ .macOS(.v10_15),
+ .iOS(.v13),
+ .tvOS(.v13),
+ .watchOS(.v6)
+ ],
+ products: [
+ .library(
+ name: "AsyncStateMachine",
+ targets: ["AsyncStateMachine"]
+ ),
+ ],
+ dependencies: [
+ .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay.git", from: "0.3.3")
+ ],
+ targets: [
+ .target(
+ name: "AsyncStateMachine",
+ dependencies: [
+ .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay")
+ ],
+ path: "Sources/"),
+ .testTarget(
+ name: "AsyncStateMachineTests",
+ dependencies: ["AsyncStateMachine"],
+ path: "Tests/"),
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..34ec0de
--- /dev/null
+++ b/README.md
@@ -0,0 +1,385 @@
+# Async State Machine
+**Async State Machine** aims to provide a way to structure an application thanks to state machines. The goal is to identify the states and the side effects involved in each feature and to model them in a consistent and scalable way thanks to a DSL.
+
+```swift
+let stateMachine = StateMachine(initial: .state1) {
+ When(state: .state1) {
+ Execute(output: .output1)
+ } transitions: {
+ On(event: .event1) { Transition(to: .state2) }
+ On(event: .event2) { Transition(to: .state3) }
+ On(event: .event3(value:)) { value in Transition(to: .state4(value)) }
+ }
+
+ When(state: .state2(value:)) { value in
+ Execute.noOutput
+ } transitions: { value in
+ …
+ }
+}
+```
+
+## Key points:
+- Each feature is a [Moore state machine](https://en.wikipedia.org/wiki/Moore_machine): no need for a global store
+- State machines are declarative: a DSL offers a natural and concise syntax
+- Swift concurrency is at the core:
+ - A state machine is an `AsyncSequence`
+ - Each side effect runs inside a `Task` that benefits from cooperative cancellation
+ - Concurrent transitions can suspend
+- State machines are built in complete isolation: tests dont require mocks
+- Dependencies are injected per side effect: no global bag of dependencies
+- State machines are not UI related: it works with UIKit or SwiftUI
+
+## A Simple Example
+As a picture is worth a thousand words, here’s an example of a state machine that drives the opening of an elevator‘s door:
+
+![](Elevator.jpeg)
+
+
+
+### How does it read?
+
+- **INITIALLY**, the elevator is `open` with 0 person inside
+- **WHEN** the state is `open`, **ON** the event `personsHaveEntered`, the new state is `open` with `n + x` persons.
+- **WHEN** the state is `open`, **ON** the event `closeButtonWasPressed`, the new state is `closing` if there is less than 10 persons (elevator’s capacity is limited).
+- **WHEN** the state is `closing`, the `close` action is executed (the door can close at different speeds).
+- **WHEN** the state is `closing`, **ON** the event `doorHasLocked`, the new state is *closed*.
+
+### What defines this state machine?
+
+- The elevator can be in 3 exclusive **states**: `open`, `closing` and `closed`. _This is the finite set of possible **states**. The initial state of the elevator is `open` with 0 person inside._
+- The elevator can receive 3 **events**: `personsHaveEntered`, `closeButtonWasPressed` and `doorHasLocked`. _This is the finite set of possible **events**._
+- The elevator can perform 1 **action**: `close` the door when the state is `closing` and the number of persons is less than 10. The speed of the doors is determined by the number of persons inside. _This is the finite set of possible **outputs**._
+- The elevator can go from one state to another when events are received. _This is the finite set of possible **transitions**._
+
+The assumption we make is that almost any feature can be described in terms of state machines. And to make it as simple as possible, we use a Domain Specific Language.
+
+## The state machine DSL
+
+Here’s the translation of the aforementioned state machine using enums and the **Async State Machine** DSL:
+
+```swift
+enum State: DSLCompatible {
+ case open(persons: Int)
+ case closing(persons: Int)
+ case closed
+}
+
+enum Event: DSLCompatible {
+ case personsHaveEntered(persons: Int)
+ case closeButtonWasPressed
+ case doorHasLocked
+}
+
+enum Output: DSLCompatible {
+ case close(speed: Int)
+}
+
+let stateMachine = StateMachine(initial: State.open(persons: 0)) {
+ When(state: State.open(persons:)) { _ in
+ Execute.noOutput
+ } transitions: { persons in
+ On(event: Event.personsHaveEntered(persons:)) { newPersons in
+ Transition(to: State.open(persons: persons + newPersons))
+ }
+
+ On(event: Event.closeButtonWasPressed) {
+ Guard(predicate: persons < 10)
+ } transition: {
+ Transition(to: State.closing(persons: persons))
+ }
+ }
+
+ When(state: State.closing(persons:)) { persons in
+ Execute(output: Output.close(speed: persons > 5 ? 1 : 2))
+ } transitions: { _ in
+ On(event: Event.doorHasLocked) {
+ Transition(to: State.closed)
+ }
+ }
+}
+```
+
+The only requirement to be able to use enums with the DSL is to have them conform to *DSLCompatible* (which allows to use enums in a declarative manner, without the need for pattern matching).
+
+## The Runtime
+
+The DSL aims to describe a formal state machine: no side effects, only pure functions!
+
+The `StateMachine` declares **output** _values_ to describe the _intent_ of side effects to be performed, but the _implementation_ of those side effects are declared in the `Runtime` where one can map outputs to side effect functions.
+
+(Amongst other benefits, this decoupling allows for easier testing of your State Machine without depending on the implementation of the side effects.)
+
+
+```swift
+func close(speed: Int) async -> Event {
+ try? await Task.sleep(nanoseconds: UInt64(1_000_000_000 / speed))
+ return .doorHasLocked
+}
+
+let runtime = Runtime()
+ .map(output: Output.close(speed:), to: close(speed:))
+```
+
+Side effects are `async` functions that return either a single `Event`, or an `AsyncSequence`. Every time the state machine produces the expected `output`, the corresponding side effect will be executed.
+
+In addition, the Runtime can register _middleware_ functions executed on any `state` or `event`:
+
+```swift
+let runtime = Runtime()
+ .map(output: Output.close(speed:), to: close(speed:))
+ .register(middleware: { (state: State) in print("State: \(state)") })
+ .register(middleware: { (event: Event) in print("Event: \(event)") })
+```
+
+The `AsyncStateMachineSequence` can then be instantiated:
+
+```swift
+let sequence = AsyncStateMachineSequence(
+ stateMachine: stateMachine,
+ runtime: runtime
+)
+
+for await state in sequence { … }
+
+await sequence.send(Event.personsHaveEntered(persons: 3))
+```
+
+## Swift concurrency at the core
+
+**Async State Machine** is 100% built with the Swift 5.5 concurrency model in mind.
+
+### Transitions
+
+- Transitions defined in the DSL are `async` functions; they will be executed in a non blocking way.
+- Transitions cannot If an event previously sent is being processed by a transition, the next call to `send(_:)` will `await`. This prevents concurrent transitions to happen simultaneously (which could otherwise lead to inconsistent states).
+
+### Side effects
+
+- Side effects are `async` functions executed in the context of `Tasks`.
+- Task priority can be set in the Runtime: `.map(output: Output.close(speed:), to: close(speed:), priority: .high)`.
+- Collaborative task cancellation applies: when an AsyncStateMachineSequence is deinit, all the pending side effect tasks will be marked as cancelled.
+
+### Async sequence
+
+- `AsyncStateMachineSequence` benefits from all the operators associated to `AsyncSequence` (`map`, `filter`, …). (See also [swift async algorithms](https://github.com/apple/swift-async-algorithms))
+- `AsyncStateMachineSequence` is compliant with a multiple producer / multiple consumer mode in a concurrent mode. Although to output is not shared (meaning each consumer will receive the successive versions of the state), the transitions are guaranteed concurrent-safe.
+
+## How to inject dependencies?
+
+Most of the time, side effects will require dependencies to perform their duty. However, **Async State Machine** expects a side effect to be a function that eventually takes a parameter (from the `Output`) and returns an `Event` or an `AsyncSequence`. There is no place for dependencies in their signatures.
+
+There are several ways to overcome this:
+
+- Make a business object that captures the dependencies and declares a function that matches the side effect’s signature:
+
+```swift
+class ElevatorUseCase {
+ let engine: Engine
+
+ init(engine: Engine) { self.engine = engine }
+
+ func close(speed: Int) async -> Event {
+ try? await Task.sleep(nanoseconds: UInt64(self.engine.delay / speed))
+ return .doorHasLocked
+ }
+}
+
+let useCase = ElevatorUseCase(engine: FastEngine())
+let runtime = Runtime()
+ .map(output: Output.close(speed:), to: useCase.close(speed:))
+```
+
+- Make a factory function that provides a side effect, capturing its dependencies:
+
+```swift
+func makeClose(engine: Engine) -> (Int) async -> Event {
+ return { (speed: Int) in
+ try? await Task.sleep(nanoseconds: UInt64(engine.delay / speed))
+ return .doorHasLocked
+ }
+}
+
+let close = makeClose(engine: FastEngine())
+let runtime = Runtime()
+ .map(output: Output.close(speed:), to: close)
+```
+
+- Use the provided `inject` function (preferred way verbosity wise):
+
+```swift
+func close(speed: Int, engine: Engine) async -> Event {
+ try? await Task.sleep(nanoseconds: UInt64(engine.delay / speed))
+ return .doorHasLocked
+}
+
+let closeSideEffect = inject(dep: Engine(), in: close(speed:engine:))
+let runtime = Runtime()
+ .map(output: Output.close(speed:), to: closeSideEffect)
+```
+
+## Testable in complete isolation
+
+State machine definitions do not depend on any dependencies, thus they can be tested without using mocks. **Async State Machine** provides a unit test helper making it even easier:
+
+```swift
+XCTStateMachine(stateMachine)
+ .assertNoOutput(when: .open(persons: 0))
+ .assert(
+ when: .open(persons: 0),
+ on: .personsHaveEntered(persons: 1),
+ transitionTo: .open(persons: 1)
+ )
+ .assert(
+ when: .open(persons: 5),
+ on: .closeButtonWasPressed,
+ transitionTo: .closing(persons: 5)
+ )
+ .assertNoTransition(when: .open(persons: 15), on: .closeButtonWasPressed)
+ .assert(when: .closing(persons: 1), execute: .close(speed: 2))
+ .assert(
+ when: .closing(persons: 1),
+ on: .doorHasLocked,
+ transitionTo: .closed
+ )
+ .assertNoOutput(when: .closed)
+```
+
+## Using Async State Machine with SwiftUI and UIKit
+
+No matter the UI framework you use, rendering a user interface is about interpreting a state. You can use an `AsyncStateMachineSequence` as a reliable state factory, exposing the state with the provided `ViewState`.
+
+A simple and naive SwiftUI usage could be:
+
+```swift
+struct ContentView: View {
+ @ObservedObject viewState: ViewState
+
+ var body: some View {
+ VStack {
+ Text(self.viewState.state.description)
+ Button {
+ Task {
+ await self.viewState.send(Event.personsHaveEntered(persons: 1))
+ }
+ } label: {
+ Text("New person")
+ }
+ Button {
+ Task {
+ await self.viewState.send(Event.closeButtonWasPressed)
+ }
+ } label: {
+ Text("Close the door")
+ }
+ }.task {
+ await viewState.start()
+ }
+ }
+}
+
+…
+
+let viewState = ViewState(myAsyncStateMachine)
+ContentView(viewState: viewState)
+```
+
+With UIKit, a simple and naive approach would be:
+
+```swift
+let task: Task!
+let viewState: ViewState!
+let cancellable = AnyCancellable()
+
+override func viewDidLoad() {
+ super.viewDidLoad()
+ self.task = Task { [weak self] in
+ await self?.viewState.start()
+ }
+
+ self.cancellable = self.viewState.$state.sink { [weak self] state in
+ self?.render(state: state)
+ }
+}
+
+func render(state: State) {
+ …
+}
+
+func deinit() {
+ self.task.cancel()
+}
+```
+
+## Extras
+
+### Conditionally resumable `send()` function
+
+Allows to send an event while awaiting for a specific state or set of states to resume.
+
+```swift
+await viewState.send(
+ .closeButtonWasPressed,
+ resumeWhen: .closed
+)`
+```
+
+### Side effect cancellation
+
+Make `close(speed:)` side effect execution be cancelled when the state machine produces any new states. It is also possible to cancel on a specific state.
+
+```swift
+Runtime.map(
+ output: Output.close(speed:),
+ to: close(speed:),
+ strategy: .cancelWhenAnyState
+)
+```
+
+### States set
+
+Allows to factorize the same transition for a set of states.
+
+```swift
+When(states: OneOf {
+ State.closing(persons:),
+ State.closed
+}) { _ in
+ Execute.noOutput
+} transitions: {
+ On(event: Event.closeButtonWasPressed) { _ in
+ Transition(to: State.opening)
+ }
+}`
+```
+
+### SwiftUI bindings
+
+Allows to create a SwiftUI binding on the current state, sending an Event when the binding changes.
+
+```swift
+self.viewState.binding(send: .closeButtonWasPressed)
+```
+
+Allows to create a SwiftUI binding on a property of the current state, sending an Event when the binding changes.
+
+```swift
+self.viewState.binding(keypath: \.persons, send: .closeButtonWasPressed)
+```
+
+### Connecting two state machines
+
+This will send the event `OtherEvent.refresh` in the other state machine when the first state machine's state is `State.closed`.
+
+```swift
+let channel = Channel()
+
+let runtime = Runtime()
+ ...
+ .connectAsSender(to: channel, when: State.closed, send: OtherEvent.refresh)
+
+
+let otherRuntime = Runtime()
+ ...
+ .connectAsReceiver(to: channel)
+```
diff --git a/Sample/Sample.xcodeproj/project.pbxproj b/Sample/Sample.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..93110b4
--- /dev/null
+++ b/Sample/Sample.xcodeproj/project.pbxproj
@@ -0,0 +1,370 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 55;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 59211C71286B873E00067F44 /* StateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59211C70286B873E00067F44 /* StateMachine.swift */; };
+ 59211C78286B887700067F44 /* AsyncStateMachine in Frameworks */ = {isa = PBXBuildFile; productRef = 59211C77286B887700067F44 /* AsyncStateMachine */; };
+ 59872A2D286B86A6004F4157 /* SampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59872A2C286B86A6004F4157 /* SampleApp.swift */; };
+ 59872A2F286B86A6004F4157 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59872A2E286B86A6004F4157 /* ContentView.swift */; };
+ 59872A31286B86A8004F4157 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 59872A30286B86A8004F4157 /* Assets.xcassets */; };
+ 59872A34286B86A8004F4157 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 59872A33286B86A8004F4157 /* Preview Assets.xcassets */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 59211C6E286B870900067F44 /* AsyncStateMachine */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AsyncStateMachine; path = ..; sourceTree = ""; };
+ 59211C70286B873E00067F44 /* StateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateMachine.swift; sourceTree = ""; };
+ 59872A29286B86A6004F4157 /* Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sample.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 59872A2C286B86A6004F4157 /* SampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleApp.swift; sourceTree = ""; };
+ 59872A2E286B86A6004F4157 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 59872A30286B86A8004F4157 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 59872A33286B86A8004F4157 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 59872A26286B86A6004F4157 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 59211C78286B887700067F44 /* AsyncStateMachine in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 59211C76286B887700067F44 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 59872A20286B86A6004F4157 = {
+ isa = PBXGroup;
+ children = (
+ 59211C6E286B870900067F44 /* AsyncStateMachine */,
+ 59872A2B286B86A6004F4157 /* Sample */,
+ 59872A2A286B86A6004F4157 /* Products */,
+ 59211C76286B887700067F44 /* Frameworks */,
+ );
+ sourceTree = "";
+ };
+ 59872A2A286B86A6004F4157 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 59872A29286B86A6004F4157 /* Sample.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 59872A2B286B86A6004F4157 /* Sample */ = {
+ isa = PBXGroup;
+ children = (
+ 59211C70286B873E00067F44 /* StateMachine.swift */,
+ 59872A2C286B86A6004F4157 /* SampleApp.swift */,
+ 59872A2E286B86A6004F4157 /* ContentView.swift */,
+ 59872A30286B86A8004F4157 /* Assets.xcassets */,
+ 59872A32286B86A8004F4157 /* Preview Content */,
+ );
+ path = Sample;
+ sourceTree = "";
+ };
+ 59872A32286B86A8004F4157 /* Preview Content */ = {
+ isa = PBXGroup;
+ children = (
+ 59872A33286B86A8004F4157 /* Preview Assets.xcassets */,
+ );
+ path = "Preview Content";
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 59872A28286B86A6004F4157 /* Sample */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 59872A37286B86A8004F4157 /* Build configuration list for PBXNativeTarget "Sample" */;
+ buildPhases = (
+ 59872A25286B86A6004F4157 /* Sources */,
+ 59872A26286B86A6004F4157 /* Frameworks */,
+ 59872A27286B86A6004F4157 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Sample;
+ packageProductDependencies = (
+ 59211C77286B887700067F44 /* AsyncStateMachine */,
+ );
+ productName = Sample;
+ productReference = 59872A29286B86A6004F4157 /* Sample.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 59872A21286B86A6004F4157 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1340;
+ LastUpgradeCheck = 1340;
+ TargetAttributes = {
+ 59872A28286B86A6004F4157 = {
+ CreatedOnToolsVersion = 13.4.1;
+ };
+ };
+ };
+ buildConfigurationList = 59872A24286B86A6004F4157 /* Build configuration list for PBXProject "Sample" */;
+ compatibilityVersion = "Xcode 13.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 59872A20286B86A6004F4157;
+ productRefGroup = 59872A2A286B86A6004F4157 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 59872A28286B86A6004F4157 /* Sample */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 59872A27286B86A6004F4157 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 59872A34286B86A8004F4157 /* Preview Assets.xcassets in Resources */,
+ 59872A31286B86A8004F4157 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 59872A25286B86A6004F4157 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 59872A2F286B86A6004F4157 /* ContentView.swift in Sources */,
+ 59872A2D286B86A6004F4157 /* SampleApp.swift in Sources */,
+ 59211C71286B873E00067F44 /* StateMachine.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 59872A35286B86A8004F4157 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.5;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 59872A36286B86A8004F4157 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.5;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 59872A38286B86A8004F4157 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"Sample/Preview Content\"";
+ DEVELOPMENT_TEAM = 3V5265LQM9;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.warpfactor.Sample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 59872A39286B86A8004F4157 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "\"Sample/Preview Content\"";
+ DEVELOPMENT_TEAM = 3V5265LQM9;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.warpfactor.Sample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 59872A24286B86A6004F4157 /* Build configuration list for PBXProject "Sample" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 59872A35286B86A8004F4157 /* Debug */,
+ 59872A36286B86A8004F4157 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 59872A37286B86A8004F4157 /* Build configuration list for PBXNativeTarget "Sample" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 59872A38286B86A8004F4157 /* Debug */,
+ 59872A39286B86A8004F4157 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 59211C77286B887700067F44 /* AsyncStateMachine */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = AsyncStateMachine;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 59872A21286B86A6004F4157 /* Project object */;
+}
diff --git a/Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/Sample/Sample.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/Sample/Sample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/Sample/Sample/Assets.xcassets/AccentColor.colorset/Contents.json b/Sample/Sample/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/Sample/Sample/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sample/Sample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sample/Sample/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..5a3257a
--- /dev/null
+++ b/Sample/Sample/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,93 @@
+{
+ "images" : [
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "2x",
+ "size" : "60x60"
+ },
+ {
+ "idiom" : "iphone",
+ "scale" : "3x",
+ "size" : "60x60"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "20x20"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "29x29"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "1x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "40x40"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "76x76"
+ },
+ {
+ "idiom" : "ipad",
+ "scale" : "2x",
+ "size" : "83.5x83.5"
+ },
+ {
+ "idiom" : "ios-marketing",
+ "scale" : "1x",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sample/Sample/Assets.xcassets/Contents.json b/Sample/Sample/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Sample/Sample/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sample/Sample/ContentView.swift b/Sample/Sample/ContentView.swift
new file mode 100644
index 0000000..2e627a5
--- /dev/null
+++ b/Sample/Sample/ContentView.swift
@@ -0,0 +1,35 @@
+//
+// ContentView.swift
+// Sample
+//
+// Created by Thibault WITTEMBERG on 28/06/2022.
+//
+
+import AsyncStateMachine
+import SwiftUI
+
+struct ContentView: View {
+ @ObservedObject var viewState: ViewState
+
+ var body: some View {
+ Text(String(describing: self.viewState.state))
+ Button {
+ Task {
+ await self.viewState.send(Event.loadingIsRequested)
+ }
+ } label: {
+ Text("Load")
+ }
+ .task {
+ await viewState.start()
+ }
+ }
+}
+
+struct ContentView_Previews: PreviewProvider {
+ @MainActor static let viewState = ViewState(asyncSequence)
+
+ static var previews: some View {
+ ContentView(viewState: viewState)
+ }
+}
diff --git a/Sample/Sample/Preview Content/Preview Assets.xcassets/Contents.json b/Sample/Sample/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Sample/Sample/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sample/Sample/SampleApp.swift b/Sample/Sample/SampleApp.swift
new file mode 100644
index 0000000..dc49689
--- /dev/null
+++ b/Sample/Sample/SampleApp.swift
@@ -0,0 +1,20 @@
+//
+// SampleApp.swift
+// Sample
+//
+// Created by Thibault WITTEMBERG on 28/06/2022.
+//
+
+import AsyncStateMachine
+import SwiftUI
+
+@main
+struct SampleApp: App {
+ @MainActor let viewState = ViewState(asyncSequence)
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView(viewState: viewState)
+ }
+ }
+}
diff --git a/Sample/Sample/StateMachine.swift b/Sample/Sample/StateMachine.swift
new file mode 100644
index 0000000..61cb6f6
--- /dev/null
+++ b/Sample/Sample/StateMachine.swift
@@ -0,0 +1,107 @@
+//
+// State.swift
+// Sample
+//
+// Created by Thibault WITTEMBERG on 28/06/2022.
+//
+
+import AsyncStateMachine
+import Foundation
+
+enum State: DSLCompatible {
+ case idle
+ case loading
+ case loaded
+}
+
+enum Event: DSLCompatible {
+ case loadingIsRequested
+ case loadingHasSucceeded
+}
+
+enum Output: DSLCompatible {
+ case load
+}
+
+let stateMachine = StateMachine(initial: .idle) {
+ When(state: .idle) { _ in
+ Execute.noOutput
+ } transitions: { _ in
+ On(event: .loadingIsRequested) { _ in
+ Transition(to: .loading)
+ }
+ }
+
+ When(state: .loading) { _ in
+ Execute(output: .load)
+ } transitions: { _ in
+ On(event: .loadingHasSucceeded) { _ in
+ Transition(to: .loaded)
+ }
+
+ On(event: .loadingIsRequested) { _ in
+ Transition(to: .loading)
+ }
+ }
+
+ When(state: .loaded) { _ in
+ Execute.noOutput
+ } transitions: { _ in
+ On(event: .loadingIsRequested) { _ in
+ Transition(to: .loading)
+ }
+
+ On(event: .loadingHasSucceeded) { _ in
+ Transition(to: .loaded)
+ }
+ }
+}
+
+let counter = Counter()
+var dateFormatterGet: DateFormatter = {
+ let fomatter = DateFormatter()
+ fomatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ return fomatter
+}()
+
+let runtime = Runtime()
+ .map(output: .load, to: {
+ let value = await counter.value
+ let begin = Date()
+ let formatBegin = begin.getFormattedDate(format: "yyyy-MM-dd HH:mm:ss")
+
+ await counter.increase()
+ print("sideEffect \(value): begin loading at \(formatBegin)")
+
+ try? await Task.sleep(nanoseconds: 5_000_000_000)
+
+ let end = Date()
+ let formatEnd = end.getFormattedDate(format: "yyyy-MM-dd HH:mm:ss")
+ print("sideEffect \(value): end loading at \(formatEnd)")
+ return .loadingHasSucceeded
+ })
+ .register(middleware: { (event: Event) in
+ print("middleware: received event \(event) on main: \(Thread.isMainThread)")
+ })
+ .register(middleware: { (state: State) in
+ print("middleware: received state \(state) on main: \(Thread.isMainThread)")
+ })
+
+actor Counter {
+ var value = 0
+
+ func increase() {
+ self.value += 1
+ }
+}
+
+
+let asyncSequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime)
+
+extension Date {
+ func getFormattedDate(format: String) -> String {
+ let dateformat = DateFormatter()
+ dateformat.dateFormat = format
+ return dateformat.string(from: self)
+ }
+}
diff --git a/Sources/AsyncStateMachineSequence.swift b/Sources/AsyncStateMachineSequence.swift
new file mode 100644
index 0000000..6461374
--- /dev/null
+++ b/Sources/AsyncStateMachineSequence.swift
@@ -0,0 +1,77 @@
+public final class AsyncStateMachineSequence: AsyncSequence, Sendable
+where S: DSLCompatible & Sendable, E: DSLCompatible & Sendable, O: DSLCompatible {
+ public typealias Element = S
+ public typealias AsyncIterator =
+ AsyncOnEachSequence>, S>>>.Iterator
+
+ let initialState: S
+ let eventChannel: AsyncBufferedChannel
+ let currentState: ManagedCriticalState
+ let engine: Engine
+ let deinitBlock: @Sendable () -> Void
+
+ public convenience init(
+ stateMachine: StateMachine,
+ runtime: Runtime
+ ) {
+ self.init(
+ stateMachine: stateMachine,
+ runtime: runtime,
+ onDeinit: nil
+ )
+ }
+
+ init(
+ stateMachine: StateMachine,
+ runtime: Runtime,
+ onDeinit: (() -> Void)? = nil
+ ) {
+ self.initialState = stateMachine.initial
+ self.eventChannel = AsyncBufferedChannel()
+ self.currentState = ManagedCriticalState(nil)
+ self.deinitBlock = {
+ runtime.channelReceivers.forEach { channelReceiver in
+ channelReceiver.update(receiver: nil)
+ }
+ onDeinit?()
+ }
+
+ self.engine = Engine(
+ resolveOutput: stateMachine.output(for:),
+ computeNextState: stateMachine.reduce(when:on:),
+ resolveSideEffect: runtime.sideEffects(for:),
+ eventMiddlewares: runtime.eventMiddlewares,
+ stateMiddlewares: runtime.stateMiddlewares
+ )
+
+ // As channals are retained as long as there is a sender using it,
+ // the receiver will also be retained.
+ // That is why it is necesssary to have a weak reference on the self here.
+ // Doing so, self will be deallocated event if a channel was using it as a receiver.
+ // Channels are resilient to nil receiver functions.
+ runtime.channelReceivers.forEach { channelReceiver in
+ channelReceiver.update { [weak self] event in
+ self?.send(event)
+ }
+ }
+ }
+
+ deinit {
+ self.eventChannel.finish()
+ self.deinitBlock()
+ }
+
+ @Sendable public func send(_ event: E) {
+ self.eventChannel.send(event)
+ }
+
+ public func makeAsyncIterator() -> AsyncIterator {
+ self
+ .eventChannel
+ .onEach { [weak self] event in await self?.engine.process(event:event) }
+ .compactScan(self.initialState, self.engine.computeNextState)
+ .serial()
+ .onEach { [weak self] state in await self?.engine.process(state: state, sendBackEvent: self?.send(_:)) }
+ .makeAsyncIterator()
+ }
+}
diff --git a/Sources/Engine.swift b/Sources/Engine.swift
new file mode 100644
index 0000000..3e94944
--- /dev/null
+++ b/Sources/Engine.swift
@@ -0,0 +1,197 @@
+//
+// Engine.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+struct TaskInProgress {
+ let cancellationPredicate: (S) -> Bool
+ let task: Task
+}
+
+actor Engine: Sendable
+where S: DSLCompatible, E: DSLCompatible & Sendable, O: DSLCompatible {
+ let resolveOutput: @Sendable (S) -> O?
+ let computeNextState: @Sendable (S, E) async -> S?
+ let resolveSideEffect: @Sendable (O) -> SideEffect?
+ let onDeinit: (() -> Void)?
+
+ var eventMiddlewares: OrderedStorage>
+ var stateMiddlewares: OrderedStorage>
+ var tasksInProgress: OrderedStorage>
+
+ init(
+ resolveOutput: @Sendable @escaping (S) -> O?,
+ computeNextState: @Sendable @escaping (S, E) async -> S?,
+ resolveSideEffect: @Sendable @escaping (O) -> SideEffect?,
+ eventMiddlewares: [Middleware],
+ stateMiddlewares: [Middleware],
+ onDeinit: (() -> Void)? = nil
+ ) {
+ self.resolveOutput = resolveOutput
+ self.computeNextState = computeNextState
+ self.resolveSideEffect = resolveSideEffect
+ self.stateMiddlewares = OrderedStorage(contentOf: stateMiddlewares)
+ self.eventMiddlewares = OrderedStorage(contentOf: eventMiddlewares)
+ self.tasksInProgress = OrderedStorage()
+ self.onDeinit = onDeinit
+ }
+
+ @discardableResult
+ func register(
+ taskInProgress task: Task,
+ cancelOn predicate: @Sendable @escaping (S) -> Bool
+ ) -> Task {
+ // registering task for eventual cancellation
+ let taskIndex = self.tasksInProgress.append(
+ TaskInProgress(
+ cancellationPredicate: predicate,
+ task: task
+ )
+ )
+
+ // cleaning when task is done
+ return Task { [weak self] in
+ await task.value
+ await self?.removeTaskInProgress(index: taskIndex)
+ }
+ }
+
+ func removeTaskInProgress(index: Int) {
+ self.tasksInProgress.remove(index: index)
+ }
+
+ func removeTasksInProgress() {
+ self.tasksInProgress.removeAll()
+ }
+
+ func cancelTasksInProgress(
+ for state: S
+ ) {
+ let tasksInProgress = self
+ .tasksInProgress
+ .indexedValues
+ .filter { _, taskInProgress in taskInProgress.cancellationPredicate(state) }
+
+ for (index, taskInProgress) in tasksInProgress {
+ taskInProgress.task.cancel()
+ self.removeTaskInProgress(index: index)
+ }
+ }
+
+ func cancelTasksInProgress() {
+ self
+ .tasksInProgress
+ .values
+ .forEach { taskInProgress in taskInProgress.task.cancel() }
+
+ self.removeTasksInProgress()
+ }
+
+ @discardableResult
+ func process(
+ event: E
+ ) async -> [Task] {
+ // executes event middlewares for this event
+ self.process(
+ middlewares: self.eventMiddlewares.indexedValues,
+ using: event,
+ removeMiddleware: { [weak self] index in await self?.removeEventMiddleware(index: index) }
+ )
+ }
+
+ @discardableResult
+ func process(
+ state: S,
+ sendBackEvent: (@Sendable (E) -> Void)?
+ ) async -> [Task] {
+ // cancels tasks that are known to be cancellable for this state
+ self.cancelTasksInProgress(for: state)
+
+ // executes state middlewares for this state
+ let removeTasksInProgressTasks = self.process(
+ middlewares: self.stateMiddlewares.indexedValues,
+ using: state,
+ removeMiddleware: { [weak self] index in await self?.removeStateMiddleware(index: index) }
+ )
+
+ // executes side effect for this state if any
+ await self.executeSideEffect(for: state, sendBackEvent: sendBackEvent)
+
+ return removeTasksInProgressTasks
+ }
+
+ @discardableResult
+ func process(
+ middlewares: [(Int, Middleware)],
+ using value: T,
+ removeMiddleware: @escaping (Int) async -> Void
+ ) -> [Task] {
+ var removeTaskInProgressTasks = [Task]()
+
+ for (index, middleware) in middlewares {
+ let task: Task = Task(priority: middleware.priority) {
+ let shouldRemove = await middleware.execute(value)
+ if shouldRemove {
+ await removeMiddleware(index)
+ }
+ }
+
+ // middlewares are not cancelled on any specific state
+ let removeTaskInProgressTask = self.register(
+ taskInProgress: task,
+ cancelOn: { _ in false }
+ )
+ removeTaskInProgressTasks.append(removeTaskInProgressTask)
+ }
+
+ return removeTaskInProgressTasks
+ }
+
+ @discardableResult
+ func executeSideEffect(
+ for state: S,
+ sendBackEvent: (@Sendable (E) -> Void)?
+ ) async -> Task? {
+ guard
+ let output = self.resolveOutput(state),
+ let sideEffect = self.resolveSideEffect(output),
+ let events = sideEffect.execute(output) else { return nil }
+
+ let task: Task = Task(priority: sideEffect.priority) {
+ do {
+ for try await event in events {
+ sendBackEvent?(event)
+ }
+ } catch {}
+ // side effect cannot fail (should not be necessary to have a `for try await ...` loop but
+ // AnyAsyncSequence masks the non throwable nature of side effects
+ // could be fixed by saying the a side effect is (O) -> any AsyncSequence
+ }
+
+ return self.register(
+ taskInProgress: task,
+ cancelOn: sideEffect.strategy.predicate
+ )
+ }
+
+ func register(onTheFly execute: @Sendable @escaping (S) async -> Bool) {
+ self.stateMiddlewares.append(
+ Middleware(execute: execute, priority: nil)
+ )
+ }
+
+ func removeEventMiddleware(index: Int) {
+ self.eventMiddlewares.remove(index: index)
+ }
+
+ func removeStateMiddleware(index: Int) {
+ self.stateMiddlewares.remove(index: index)
+ }
+
+ deinit {
+ self.cancelTasksInProgress()
+ self.onDeinit?()
+ }
+}
diff --git a/Sources/Runtime/Channel.swift b/Sources/Runtime/Channel.swift
new file mode 100644
index 0000000..65986ac
--- /dev/null
+++ b/Sources/Runtime/Channel.swift
@@ -0,0 +1,22 @@
+//
+// Channel.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public final class Channel: Sendable
+where E: DSLCompatible {
+ typealias Receiver = @Sendable (E) -> Void
+ let receiver = ManagedCriticalState(nil)
+
+ public init() {}
+
+ func push(_ event: E) {
+ self.receiver.criticalState?(event)
+ }
+
+ func register(receiver: @Sendable @escaping (E) -> Void) {
+ self.receiver.apply(criticalState: receiver)
+ }
+}
diff --git a/Sources/Runtime/ExecutionStrategy.swift b/Sources/Runtime/ExecutionStrategy.swift
new file mode 100644
index 0000000..e30c5f5
--- /dev/null
+++ b/Sources/Runtime/ExecutionStrategy.swift
@@ -0,0 +1,46 @@
+//
+// ExecutionStrategy.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+
+public struct ExecutionStrategy: Sendable, Equatable
+where S: DSLCompatible {
+ enum Identifier {
+ case cancelWhenState
+ case cancelWhenStateWithAssociatedValue
+ case cancelWhenAnyState
+ case continueWhenAnyState
+ }
+
+ let id: Identifier
+ let predicate: @Sendable (S) -> Bool
+
+ public static func cancel(when state: S) -> ExecutionStrategy {
+ ExecutionStrategy(id: .cancelWhenState) { input in
+ input.matches(state)
+ }
+ }
+
+ public static func cancel(
+ when state: @escaping (StateAssociatedValue) -> S
+ ) -> ExecutionStrategy {
+ ExecutionStrategy(id: .cancelWhenStateWithAssociatedValue) { input in
+ input.matches(state)
+ }
+ }
+
+ public static var cancelWhenAnyState: ExecutionStrategy {
+ ExecutionStrategy(id: .cancelWhenAnyState) { _ in true }
+ }
+
+ public static var continueWhenAnyState: ExecutionStrategy {
+ ExecutionStrategy(id: .continueWhenAnyState) { _ in false }
+ }
+
+ public static func == (lhs: ExecutionStrategy, rhs: ExecutionStrategy) -> Bool {
+ lhs.id == rhs.id
+ }
+}
diff --git a/Sources/Runtime/Middleware.swift b/Sources/Runtime/Middleware.swift
new file mode 100644
index 0000000..f68ec2b
--- /dev/null
+++ b/Sources/Runtime/Middleware.swift
@@ -0,0 +1,11 @@
+//
+// Middleware.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+struct Middleware {
+ let execute: @Sendable (T) async -> Bool
+ let priority: TaskPriority?
+}
diff --git a/Sources/Runtime/Runtime.swift b/Sources/Runtime/Runtime.swift
new file mode 100644
index 0000000..5963d14
--- /dev/null
+++ b/Sources/Runtime/Runtime.swift
@@ -0,0 +1,217 @@
+//
+// Runtime.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+final class ChannelReceiver: Sendable
+where E: DSLCompatible {
+ typealias Receiver = @Sendable (E) -> Void
+ let receiver = ManagedCriticalState(nil)
+
+ func receive(_ event: E) {
+ self.receiver.criticalState?(event)
+ }
+
+ func update(receiver: Receiver?) {
+ self.receiver.apply(criticalState: receiver)
+ }
+}
+
+public struct Runtime: Sendable
+where S: DSLCompatible, E: DSLCompatible & Sendable, O: DSLCompatible {
+ var sideEffects = [SideEffect]()
+ var stateMiddlewares = [Middleware]()
+ var eventMiddlewares = [Middleware]()
+ var channelReceivers = [ChannelReceiver]()
+
+ public init() {}
+
+ @discardableResult
+ public func map(
+ output: O,
+ to sideEffect: @Sendable @escaping () -> AS,
+ priority: TaskPriority? = nil,
+ strategy: ExecutionStrategy = .continueWhenAnyState
+ ) -> Self where AS.Element == E {
+ var mutableSelf = self
+
+ let predicate: @Sendable (O) -> Bool = { currentOutput in
+ currentOutput.matches(output)
+ }
+
+ let sideEffect: @Sendable (O) -> AnyAsyncSequence = { _ in
+ sideEffect().eraseToAnyAsyncSequence()
+ }
+
+ mutableSelf.sideEffects.append(
+ SideEffect(
+ predicate: predicate,
+ execute: sideEffect,
+ priority: priority,
+ strategy: strategy
+ )
+ )
+
+ return mutableSelf
+ }
+
+ @discardableResult
+ public func map(
+ output: O,
+ to sideEffect: @Sendable @escaping () async -> E?,
+ priority: TaskPriority? = nil,
+ strategy: ExecutionStrategy = .continueWhenAnyState
+ ) -> Self {
+ let sideEffect: @Sendable () -> AnyAsyncSequence = {
+ AsyncJustSequence(sideEffect)
+ .eraseToAnyAsyncSequence()
+ }
+
+ return self.map(
+ output: output,
+ to: sideEffect,
+ priority: priority,
+ strategy: strategy
+ )
+ }
+
+ @discardableResult
+ public func map(
+ output: @escaping (OutputAssociatedValue) -> O,
+ to sideEffect: @Sendable @escaping (OutputAssociatedValue) -> AS,
+ priority: TaskPriority? = nil,
+ strategy: ExecutionStrategy = .continueWhenAnyState
+ ) -> Self where AS.Element == E {
+ var mutableSelf = self
+
+ let predicate: @Sendable (O) -> Bool = { currentOutput in
+ currentOutput.matches(output)
+ }
+
+ let sideEffect: @Sendable (O) -> AnyAsyncSequence? = { currentOutput in
+ if let outputAssociatedValue = currentOutput.associatedValue(expecting: OutputAssociatedValue.self) {
+ return sideEffect(outputAssociatedValue).eraseToAnyAsyncSequence()
+ }
+
+ return nil
+ }
+
+ mutableSelf.sideEffects.append(
+ SideEffect(
+ predicate: predicate,
+ execute: sideEffect,
+ priority: priority,
+ strategy: strategy
+ )
+ )
+
+ return mutableSelf
+ }
+
+ @discardableResult
+ public func map(
+ output: @escaping (OutputAssociatedValue) -> O,
+ to sideEffect: @Sendable @escaping (OutputAssociatedValue) async -> E?,
+ priority: TaskPriority? = nil,
+ strategy: ExecutionStrategy = .continueWhenAnyState
+ ) -> Self {
+ let sideEffect: @Sendable (OutputAssociatedValue) -> AnyAsyncSequence = { outputAssociatedValue in
+ return AsyncJustSequence({ await sideEffect(outputAssociatedValue) })
+ .eraseToAnyAsyncSequence()
+ }
+
+ return self.map(
+ output: output,
+ to: sideEffect,
+ priority: priority,
+ strategy: strategy
+ )
+ }
+
+ @discardableResult
+ public func register(
+ middleware: @Sendable @escaping (S) async -> Void,
+ priority: TaskPriority? = nil
+ ) -> Self {
+ var mutableSelf = self
+
+ mutableSelf.stateMiddlewares.append(
+ Middleware(
+ execute: { state in
+ await middleware(state)
+ return false
+ },
+ priority: priority
+ )
+ )
+
+ return mutableSelf
+ }
+
+ @discardableResult
+ public func register(
+ middleware: @Sendable @escaping (E) async -> Void,
+ priority: TaskPriority? = nil
+ ) -> Self {
+ var mutableSelf = self
+
+ mutableSelf.eventMiddlewares.append(
+ Middleware(
+ execute: { event in
+ await middleware(event)
+ return false
+ },
+ priority: priority
+ )
+ )
+
+ return mutableSelf
+ }
+
+ @discardableResult
+ public func connectAsReceiver(
+ to channel: Channel
+ ) -> Self {
+ var mutableSelf = self
+
+ let channelReceiver = ChannelReceiver()
+ channel.register { event in channelReceiver.receive(event) }
+ mutableSelf.channelReceivers.append(channelReceiver)
+
+ return mutableSelf
+ }
+
+ @discardableResult
+ public func connectAsSender(
+ to channel: Channel,
+ when state: S,
+ send event: OtherE
+ ) -> Self {
+ return self.register(middleware: { (inputState: S) in
+ guard inputState.matches(state) else { return }
+ channel.push(event)
+ })
+ }
+
+ @discardableResult
+ public func connectAsSender(
+ to channel: Channel,
+ when state: @escaping (StateAssociatedValue) -> S,
+ send event: @Sendable @escaping (StateAssociatedValue) -> OtherE
+ ) -> Self {
+ return self.register(middleware: { (inputState: S) in
+ guard let value = inputState.associatedValue(matching: state)
+ else { return }
+ channel.push(event(value))
+ })
+ }
+
+ @Sendable func sideEffects(for output: O) -> SideEffect? {
+ self
+ .sideEffects
+ .first(where: { sideEffect in sideEffect.predicate(output) })
+ }
+}
+
diff --git a/Sources/Runtime/SideEffect.swift b/Sources/Runtime/SideEffect.swift
new file mode 100644
index 0000000..442d564
--- /dev/null
+++ b/Sources/Runtime/SideEffect.swift
@@ -0,0 +1,14 @@
+//
+// SideEffect.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+struct SideEffect: Sendable
+where S: DSLCompatible {
+ let predicate: @Sendable (O) -> Bool
+ let execute: @Sendable (O) -> AnyAsyncSequence?
+ let priority: TaskPriority?
+ let strategy: ExecutionStrategy
+}
diff --git a/Sources/StateMachine/DSLCompatible.swift b/Sources/StateMachine/DSLCompatible.swift
new file mode 100644
index 0000000..d499e04
--- /dev/null
+++ b/Sources/StateMachine/DSLCompatible.swift
@@ -0,0 +1,126 @@
+//
+// DSLCompatible.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+// insppired by https://github.com/gringoireDM/EnumKit
+import Foundation
+
+/// ``DSLCompatible`` conformance allows enum types to be
+/// used in a declarative fashion by using memory fingerprint comparition
+/// or case label comparition.
+///
+/// ```
+/// enum Value: DSLCompatible {
+/// case v1
+/// case v2(value: String)
+/// }
+///
+/// let me = Value.v2("foo")
+///
+/// me.matches(Value.v2(value:)) // true
+/// me.matches(Value.v1) // false
+///
+/// me.associatedValue(expecting: String.self) // returns "foo"?
+/// me.associatedValue(expecting: Int.self) // returns nil
+/// ```
+public protocol DSLCompatible {}
+
+struct NotAnEnumError: Error {}
+
+public extension DSLCompatible {
+ /// if Self is an enum: returns the label of the case
+ /// if Self is a type with some properties: returns the label of the first property
+ /// if Self is a type without properties: returns the description of the type
+ var label: String {
+ return Mirror(reflecting: self).children.first?.label ?? String(describing: self)
+ }
+
+ /// Determines if `self` matches an enum case from a memory fingerprint perspective or a label perspective.
+ ///
+ /// ```
+ /// enum Value: DSLCompatible {
+ /// case v1
+ /// case v2
+ /// }
+ ///
+ /// let me = Value.v2
+ ///
+ /// me.matches(Value.v2) // true
+ /// me.matches(Value.v1) // false
+ /// ```
+ ///
+ /// - Parameter other: the value against which we are looking for a match.
+ /// - Returns: true when `other` matches `self`, false otherwise,
+ func matches(_ other: Self) -> Bool {
+ var me = self
+ var other = other
+ // compare memory bitwise (should be the priviledged comparaison point)
+ return memcmp(&me, &other, MemoryLayout.size) == 0 || me.label == other.label
+ }
+
+
+ /// Determines if `self` matches an enum case signature that has associated values.
+ ///
+ /// ```
+ /// enum Value: DSLCompatible {
+ /// case v1(value: Int)
+ /// case v2(value: String)
+ /// }
+ ///
+ /// let me = Value.v2(value: "foo")
+ ///
+ /// me.matches(Value.v2(value:)) // true
+ /// me.matches(Value.v1(value:)) // false
+ /// ```
+ ///
+ /// - Parameter definition: the signature of the enum case
+ /// - Returns: true when self matches the `definition`, false otherwise,
+ func matches(_ definition: (AssociatedValue) -> Self) -> Bool {
+ return associatedValue(matching: definition) != nil
+ }
+
+ /// Extracts the associated value from `self` when `self` is an enum case with associated values of the expected type.
+ /// - Parameter expecting: the expected type of the associated value.
+ /// - Returns: the value of the associated type when the expected type matches the actual associated value, nil otherwise.
+ func associatedValue(expecting: AssociatedValue.Type) -> AssociatedValue? {
+ return decompose(expecting: expecting)?.associatedValue
+ }
+
+ /// Extracts the associated value from `self` when `self` is an enum case that matches the given enum case signature.
+ /// - Parameter definition: the signature of the enum case
+ /// - Returns: the value of the associated type when `self` matches the given enum signature, nil otherwise.
+ func associatedValue(matching definition: (AssociatedValue) -> Self) -> AssociatedValue? {
+ guard
+ let me: (path: [String?], associatedValue: AssociatedValue) = decompose(expecting: AssociatedValue.self),
+ let other: (path: [String?], associatedValue: AssociatedValue) = definition(me.associatedValue).decompose(expecting: AssociatedValue.self),
+ me.path == other.path else { return nil }
+ return me.associatedValue
+ }
+}
+
+extension DSLCompatible {
+ func decompose(
+ expecting: AssociatedValue.Type
+ ) -> (path: [String?], associatedValue: AssociatedValue)? {
+ let mirror = Mirror(reflecting: self)
+
+ guard mirror.displayStyle == .enum else { return nil }
+
+ var path: [String?] = []
+ var any: Any = self
+
+ while case let (label?, anyChild)? = Mirror(reflecting: any).children.first {
+ path.append(label)
+ path.append(String(describing: type(of: anyChild)))
+ if let child = anyChild as? AssociatedValue { return (path, child) }
+ any = anyChild
+ }
+ if MemoryLayout.size == 0 {
+ return (["\(self)"], unsafeBitCast((), to: AssociatedValue.self))
+ }
+ return nil
+ }
+}
diff --git a/Sources/StateMachine/Execute.swift b/Sources/StateMachine/Execute.swift
new file mode 100644
index 0000000..542433e
--- /dev/null
+++ b/Sources/StateMachine/Execute.swift
@@ -0,0 +1,23 @@
+//
+// Execute.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public struct Execute
+where O: DSLCompatible {
+ let output: O?
+
+ init() {
+ self.output = nil
+ }
+
+ public init(output: O) {
+ self.output = output
+ }
+
+ public static var noOutput: Execute {
+ Execute()
+ }
+}
diff --git a/Sources/StateMachine/Guard.swift b/Sources/StateMachine/Guard.swift
new file mode 100644
index 0000000..b9cbf2f
--- /dev/null
+++ b/Sources/StateMachine/Guard.swift
@@ -0,0 +1,14 @@
+//
+// Guard.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public struct Guard {
+ let predicate: Bool
+
+ public init(predicate: @autoclosure () -> Bool) {
+ self.predicate = predicate()
+ }
+}
diff --git a/Sources/StateMachine/Never+DSLCompatible.swift b/Sources/StateMachine/Never+DSLCompatible.swift
new file mode 100644
index 0000000..5fd5072
--- /dev/null
+++ b/Sources/StateMachine/Never+DSLCompatible.swift
@@ -0,0 +1,8 @@
+//
+// Never+DSLCompatible.swift
+//
+//
+// Created by Thibault WITTEMBERG on 20/06/2022.
+//
+
+extension Never: DSLCompatible {}
diff --git a/Sources/StateMachine/On.swift b/Sources/StateMachine/On.swift
new file mode 100644
index 0000000..3c07d6d
--- /dev/null
+++ b/Sources/StateMachine/On.swift
@@ -0,0 +1,92 @@
+//
+// On.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public struct On: Sendable
+where S: DSLCompatible, E: DSLCompatible {
+ // predicate and transition are 2 separate things because we want
+ // to be able to evaluate predicates (which is a sync and fast operation)
+ // in isolation to select the good transition (we don't want to execute
+ // all the transitions to find out which has a non nil next state)
+ let predicate: @Sendable (E) -> Bool
+ let transition: @Sendable (E) async -> S?
+
+ public init(
+ event: E,
+ guard: @escaping @Sendable (E) -> Guard,
+ transition: @escaping @Sendable (E) async -> Transition
+ ) {
+ self.predicate = { inputEvent in
+ inputEvent.matches(event) && `guard`(inputEvent).predicate
+ }
+ self.transition = { inputEvent in await transition(inputEvent).state }
+ }
+
+ public init(
+ event: E,
+ transition: @escaping @Sendable (E) async -> Transition
+ ) {
+ self.init(
+ event: event,
+ guard: { _ in Guard(predicate: true) },
+ transition: transition
+ )
+ }
+
+ public init(
+ event: @escaping (EventAssociatedValue) -> E,
+ guard: @escaping @Sendable (EventAssociatedValue) -> Guard,
+ transition: @escaping @Sendable (EventAssociatedValue) async -> Transition
+ ) {
+ self.predicate = { inputEvent in
+ if let inputPayload = inputEvent.associatedValue(expecting: EventAssociatedValue.self) {
+ return inputEvent.matches(event) &&
+ `guard`(inputPayload).predicate
+ }
+ return false
+ }
+
+ self.transition = { inputEvent in
+ if let eventPayload = inputEvent.associatedValue(expecting: EventAssociatedValue.self) {
+ return await transition(eventPayload).state
+ }
+ return nil
+ }
+ }
+
+ public init(
+ event: @escaping (EventAssociatedValue) -> E,
+ transition: @escaping @Sendable (EventAssociatedValue) async -> Transition
+ ) {
+ self.init(
+ event: event,
+ guard: { _ in Guard(predicate: true) },
+ transition: transition
+ )
+ }
+
+ public init(
+ events: OneOf,
+ guard: @escaping @Sendable (E) -> Guard,
+ transition: @escaping @Sendable (E) async -> Transition
+ ) {
+ self.predicate = { event in
+ events.predicate(event) && `guard`(event).predicate
+ }
+ self.transition = { event in await transition(event).state }
+ }
+
+ public init(
+ events: OneOf,
+ transition: @escaping @Sendable (E) async -> Transition
+ ) {
+ self.init(
+ events: events,
+ guard: { _ in Guard(predicate: true) },
+ transition: transition
+ )
+ }
+}
diff --git a/Sources/StateMachine/OneOf.swift b/Sources/StateMachine/OneOf.swift
new file mode 100644
index 0000000..55f7a4e
--- /dev/null
+++ b/Sources/StateMachine/OneOf.swift
@@ -0,0 +1,43 @@
+//
+// OneOf.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public struct OneOf: Sendable
+where T: DSLCompatible {
+ let predicate: @Sendable (T) -> Bool
+
+ init(predicate: @escaping @Sendable (T) -> Bool) {
+ self.predicate = predicate
+ }
+
+ public init(@OneOfBuilder _ oneOf: () -> OneOf) {
+ self = oneOf()
+ }
+}
+
+@resultBuilder
+public enum OneOfBuilder
+where T: DSLCompatible {
+ public static func buildExpression(
+ _ expression: T
+ ) -> (T) -> Bool {
+ { input in
+ input.matches(expression)
+ }
+ }
+
+ public static func buildExpression(
+ _ expression: @escaping (AssociatedValue) -> T
+ ) -> (T) -> Bool {
+ { input in
+ input.matches(expression)
+ }
+ }
+
+ public static func buildBlock(_ components: ((T) -> Bool)...) -> OneOf {
+ OneOf(predicate: { input in components.contains { $0(input) } })
+ }
+}
diff --git a/Sources/StateMachine/StateMachine.swift b/Sources/StateMachine/StateMachine.swift
new file mode 100644
index 0000000..25a72d1
--- /dev/null
+++ b/Sources/StateMachine/StateMachine.swift
@@ -0,0 +1,52 @@
+//
+// StateMachine.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public struct StateMachine: Sendable
+where S: DSLCompatible & Sendable, E: DSLCompatible, O: DSLCompatible {
+ let initial: S
+ let whenStates: [When]
+
+ public init(
+ initial: S,
+ @WhensBuilder whenStates: () -> [When]
+ ) {
+ self.initial = initial
+ self.whenStates = whenStates()
+ }
+
+ @Sendable func output(for state: S) -> O? {
+ self
+ .whenStates
+ .first { $0.predicate(state) }?
+ .output(state)
+ }
+
+ @Sendable func reduce(when state: S, on event: E) async -> S? {
+ await self
+ .whenStates
+ .filter { $0.predicate(state) }
+ .flatMap { $0.transitions(state) }
+ .first { $0.predicate(event) }?
+ .transition(event)
+ }
+}
+
+@resultBuilder
+public enum WhensBuilder
+where S: DSLCompatible, E: DSLCompatible, O: DSLCompatible {
+ public static func buildExpression(
+ _ expression: When
+ ) -> When {
+ expression
+ }
+
+ public static func buildBlock(
+ _ components: When...
+ ) -> [When] {
+ components
+ }
+}
diff --git a/Sources/StateMachine/Transition.swift b/Sources/StateMachine/Transition.swift
new file mode 100644
index 0000000..d95b266
--- /dev/null
+++ b/Sources/StateMachine/Transition.swift
@@ -0,0 +1,15 @@
+//
+// Transition.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public struct Transition
+where S: DSLCompatible {
+ let state: S
+
+ public init(to state: S) {
+ self.state = state
+ }
+}
diff --git a/Sources/StateMachine/When.swift b/Sources/StateMachine/When.swift
new file mode 100644
index 0000000..f783f18
--- /dev/null
+++ b/Sources/StateMachine/When.swift
@@ -0,0 +1,118 @@
+//
+// When.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public struct When: Sendable
+where S: DSLCompatible, E: DSLCompatible, O: DSLCompatible {
+ let predicate: @Sendable (S) -> Bool
+ let output: @Sendable (S) -> O?
+ let transitions: @Sendable (S) -> [On]
+
+ init(
+ predicate: @Sendable @escaping (S) -> Bool,
+ output: @Sendable @escaping (S) -> O?,
+ transitions: @Sendable @escaping (S) -> [On]
+ ) {
+ self.predicate = predicate
+ self.output = output
+ self.transitions = transitions
+ }
+
+ public init(
+ states: OneOf,
+ execute: @Sendable @escaping (S) -> Execute,
+ @TransitionsBuilder transitions: @Sendable @escaping (S) -> [On]
+ ) {
+ self.init(
+ predicate: states.predicate,
+ output: { inputState in execute(inputState).output },
+ transitions: transitions
+ )
+ }
+
+ public init(
+ states: OneOf,
+ execute: @Sendable @escaping (S) -> Execute
+ ) {
+ self.init(
+ states: states,
+ execute: execute,
+ transitions: { _ in }
+ )
+ }
+
+ public init(
+ state: S,
+ execute: @Sendable @escaping (S) -> Execute,
+ @TransitionsBuilder transitions: @Sendable @escaping (S) -> [On]
+ ) {
+ self.init(
+ predicate: { inputState in inputState.matches(state) },
+ output: { inputState in execute(inputState).output },
+ transitions: transitions
+ )
+ }
+
+ public init(
+ state: S,
+ execute: @Sendable @escaping (S) -> Execute
+ ) {
+ self.init(
+ state: state,
+ execute: execute,
+ transitions: { _ in }
+ )
+ }
+
+ public init(
+ state: @escaping (StateAssociatedValue) -> S,
+ execute: @Sendable @escaping (StateAssociatedValue) -> Execute,
+ @TransitionsBuilder transitions: @Sendable @escaping (StateAssociatedValue) -> [On]
+ ) {
+ self.init(
+ predicate: { inputState in inputState.matches(state) },
+ output: { inputState in
+ if let inputPayload = inputState.associatedValue(expecting: StateAssociatedValue.self) {
+ return execute(inputPayload).output
+ }
+ return nil
+ },
+ transitions: { inputState in
+ if let inputPayload = inputState.associatedValue(expecting: StateAssociatedValue.self) {
+ return transitions(inputPayload)
+ }
+ return []
+ }
+ )
+ }
+
+ public init(
+ state: @escaping (StateAssociatedValue) -> S,
+ execute: @Sendable @escaping (StateAssociatedValue) -> Execute
+ ) {
+ self.init(
+ state: state,
+ execute: execute,
+ transitions: { _ in }
+ )
+ }
+}
+
+@resultBuilder
+public enum TransitionsBuilder
+where S: DSLCompatible, E: DSLCompatible {
+ public static func buildExpression(
+ _ expression: On
+ ) -> On {
+ expression
+ }
+
+ public static func buildBlock(
+ _ components: On...
+ ) -> [On] {
+ components
+ }
+}
diff --git a/Sources/Supporting/AnyAsyncSequence.swift b/Sources/Supporting/AnyAsyncSequence.swift
new file mode 100644
index 0000000..c049b19
--- /dev/null
+++ b/Sources/Supporting/AnyAsyncSequence.swift
@@ -0,0 +1,43 @@
+//
+// AnyAsyncSequence.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public extension AsyncSequence {
+ /// Type erase the AsyncSequence into an AnyAsyncSequence.
+ /// - Returns: A type erased AsyncSequence.
+ func eraseToAnyAsyncSequence() -> AnyAsyncSequence {
+ AnyAsyncSequence(self)
+ }
+}
+
+/// Type erased version of an AsyncSequence.
+public struct AnyAsyncSequence: AsyncSequence {
+ public typealias Element = Element
+ public typealias AsyncIterator = Iterator
+
+ private let makeAsyncIteratorClosure: () -> AsyncIterator
+
+ public init(_ baseAsyncSequence: BaseAsyncSequence) where BaseAsyncSequence.Element == Element {
+ self.makeAsyncIteratorClosure = { Iterator(baseIterator: baseAsyncSequence.makeAsyncIterator()) }
+ }
+
+ public func makeAsyncIterator() -> AsyncIterator {
+ Iterator(baseIterator: self.makeAsyncIteratorClosure())
+ }
+
+ public struct Iterator: AsyncIteratorProtocol {
+ private let nextClosure: () async throws -> Element?
+
+ public init(baseIterator: BaseAsyncIterator) where BaseAsyncIterator.Element == Element {
+ var baseIterator = baseIterator
+ self.nextClosure = { try await baseIterator.next() }
+ }
+
+ public func next() async throws -> Element? {
+ try await self.nextClosure()
+ }
+ }
+}
diff --git a/Sources/Supporting/AsyncBufferedChannel.swift b/Sources/Supporting/AsyncBufferedChannel.swift
new file mode 100644
index 0000000..d95a128
--- /dev/null
+++ b/Sources/Supporting/AsyncBufferedChannel.swift
@@ -0,0 +1,185 @@
+//
+// AsyncBufferedChannel.swift
+//
+//
+// Created by Thibault WITTEMBERG on 06/08/2022.
+//
+
+public final class AsyncBufferedChannel: AsyncSequence, Sendable
+where Element: Sendable {
+ public typealias Element = Element
+ public typealias AsyncIterator = Iterator
+
+ struct Awaiting: Hashable {
+ let id: Int
+ let continuation: UnsafeContinuation?
+
+ static func placeHolder(id: Int) -> Awaiting {
+ Awaiting(id: id, continuation: nil)
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(self.id)
+ }
+
+ static func ==(lhs: Awaiting, rhs: Awaiting) -> Bool {
+ lhs.id == rhs.id
+ }
+ }
+
+ enum SendDecision {
+ case resume(Awaiting, Element)
+ case terminate([Awaiting])
+ case nothing
+ }
+
+ enum AwaitingDecision {
+ case resume(Element?)
+ case suspend
+ }
+
+ enum Value {
+ case element(Element)
+ case termination
+ }
+
+ enum State {
+ case active(queue: [Value], awaitings: Set)
+ case finished
+
+ static var initial: State {
+ .active(queue: [], awaitings: [])
+ }
+ }
+
+ let ids: ManagedCriticalState
+ let state: ManagedCriticalState
+
+ init() {
+ self.ids = ManagedCriticalState(0)
+ self.state = ManagedCriticalState(.initial)
+ }
+
+ func generateId() -> Int {
+ self.ids.withCriticalRegion { ids in
+ ids += 1
+ return ids
+ }
+ }
+
+ func send(_ value: Value) {
+ let decision = self.state.withCriticalRegion { state -> SendDecision in
+ switch state {
+ case .active(var queue, var awaitings):
+ if !awaitings.isEmpty {
+ switch value {
+ case .element(let element):
+ let awaiting = awaitings.removeFirst()
+ state = .active(queue: queue, awaitings: awaitings)
+ return .resume(awaiting, element)
+ case .termination:
+ state = .finished
+ return .terminate(Array(awaitings))
+ }
+ } else {
+ switch value {
+ case .termination where queue.isEmpty:
+ state = .finished
+ case .element, .termination:
+ queue.append(value)
+ state = .active(queue: queue, awaitings: awaitings)
+ }
+ return .nothing
+ }
+ case .finished:
+ return .nothing
+ }
+ }
+
+ switch decision {
+ case .nothing:
+ break
+ case .terminate(let awaitings):
+ awaitings.forEach { $0.continuation?.resume(returning: nil) }
+ case .resume(let awaiting, let element):
+ awaiting.continuation?.resume(returning: element)
+ }
+ }
+
+ func send(_ element: Element) {
+ self.send(.element(element))
+ }
+
+ func finish() {
+ self.send(.termination)
+ }
+
+ func next(onSuspend: (() -> Void)? = nil) async -> Element? {
+ let awaitingId = self.generateId()
+ let cancellation = ManagedCriticalState(false)
+
+ return await withTaskCancellationHandler { [state] in
+ let awaiting = state.withCriticalRegion { state -> Awaiting? in
+ cancellation.apply(criticalState: true)
+ switch state {
+ case .active(let queue, var awaitings):
+ let awaiting = awaitings.remove(.placeHolder(id: awaitingId))
+ state = .active(queue: queue, awaitings: awaitings)
+ return awaiting
+ case .finished:
+ return nil
+ }
+ }
+
+ awaiting?.continuation?.resume(returning: nil)
+ } operation: {
+ await withUnsafeContinuation { [state] (continuation: UnsafeContinuation) in
+ let decision = state.withCriticalRegion { state -> AwaitingDecision in
+ guard !cancellation.criticalState else { return .resume(nil) }
+
+ switch state {
+ case .active(var queue, var awaitings):
+ if !queue.isEmpty {
+ let value = queue.removeFirst()
+ switch value {
+ case .termination:
+ state = .finished
+ return .resume(nil)
+ case .element(let element):
+ state = .active(queue: queue, awaitings: awaitings)
+ return .resume(element)
+ }
+ } else {
+ awaitings.update(with: Awaiting(id: awaitingId, continuation: continuation))
+ state = .active(queue: queue, awaitings: awaitings)
+ return .suspend
+ }
+ case .finished:
+ return .resume(nil)
+ }
+ }
+
+ switch decision {
+ case .resume(let element): continuation.resume(returning: element)
+ case .suspend:
+ onSuspend?()
+ break
+ }
+ }
+ }
+ }
+
+ public func makeAsyncIterator() -> AsyncIterator {
+ Iterator(
+ channel: self
+ )
+ }
+
+ public struct Iterator: AsyncIteratorProtocol {
+ let channel: AsyncBufferedChannel
+
+ public func next() async -> Element? {
+ await self.channel.next()
+ }
+ }
+}
diff --git a/Sources/Supporting/AsyncCompactScanSequence.swift b/Sources/Supporting/AsyncCompactScanSequence.swift
new file mode 100644
index 0000000..eb740c1
--- /dev/null
+++ b/Sources/Supporting/AsyncCompactScanSequence.swift
@@ -0,0 +1,69 @@
+//
+// AsyncCompactScanSequence.swift
+//
+//
+// Created by Thibault WITTEMBERG on 11/08/2022.
+//
+
+extension AsyncSequence {
+ func compactScan(
+ _ initial: R,
+ _ transform: @Sendable @escaping (R, Element) async -> R?
+ ) -> AsyncCompactScanSequence {
+ AsyncCompactScanSequence(base: self, initial: initial, transform: transform)
+ }
+}
+
+public final class AsyncCompactScanSequence: AsyncSequence, Sendable
+where R: Sendable, Base: Sendable {
+ public typealias Element = R
+ public typealias AsyncIterator = Iterator
+
+ let base: Base
+ let initial: R
+ let transform: @Sendable (R, Base.Element) async -> R?
+
+ public init(base: Base, initial: R, transform: @Sendable @escaping (R, Base.Element) async -> R?) {
+ self.base = base
+ self.initial = initial
+ self.transform = transform
+ }
+
+ public func makeAsyncIterator() -> Iterator {
+ Iterator(
+ baseIterator: self.base.makeAsyncIterator(),
+ accumulator: self.initial,
+ transform: self.transform
+ )
+ }
+
+ public struct Iterator: AsyncIteratorProtocol {
+ var baseIterator: Base.AsyncIterator
+ var accumulator: R
+ var isInitial = true
+ let transform: @Sendable (R, Base.Element) async -> R?
+
+ public mutating func next() async rethrows -> Element? {
+ if isInitial {
+ isInitial = false
+ return self.accumulator
+ }
+
+ var result: R? = nil
+
+ while result == nil {
+ guard let element = try await self.baseIterator.next() else {
+ return nil
+ }
+
+ result = await self.transform(accumulator, element)
+ }
+
+ if let result = result {
+ self.accumulator = result
+ }
+
+ return result
+ }
+ }
+}
diff --git a/Sources/Supporting/AsyncJustSequence.swift b/Sources/Supporting/AsyncJustSequence.swift
new file mode 100644
index 0000000..3552562
--- /dev/null
+++ b/Sources/Supporting/AsyncJustSequence.swift
@@ -0,0 +1,34 @@
+public struct AsyncJustSequence: AsyncSequence {
+ public typealias Element = Element
+ public typealias AsyncIterator = Iterator
+
+ let element: () async -> Element?
+
+ public init(_ element: @escaping () async -> Element?) {
+ self.element = element
+ }
+
+ public func makeAsyncIterator() -> Iterator {
+ Iterator(self.element)
+ }
+
+ public struct Iterator: AsyncIteratorProtocol {
+ let element: () async -> Element?
+ var hasDelivered = false
+
+ init(_ element: @escaping () async -> Element?) {
+ self.element = element
+ }
+
+ public mutating func next() async -> Element? {
+ guard !Task.isCancelled else { return nil }
+
+ guard !self.hasDelivered else {
+ return nil
+ }
+
+ self.hasDelivered = true
+ return await self.element()
+ }
+ }
+}
diff --git a/Sources/Supporting/AsyncOnEachSequence.swift b/Sources/Supporting/AsyncOnEachSequence.swift
new file mode 100644
index 0000000..9cf572a
--- /dev/null
+++ b/Sources/Supporting/AsyncOnEachSequence.swift
@@ -0,0 +1,48 @@
+//
+// AsyncOnEachSequence.swift
+//
+//
+// Created by Thibault WITTEMBERG on 03/08/2022.
+//
+
+extension AsyncSequence {
+ func onEach(_ block: @Sendable @escaping (Element) async -> Void) -> AsyncOnEachSequence {
+ AsyncOnEachSequence(self, onEach: block)
+ }
+}
+
+public final class AsyncOnEachSequence: AsyncSequence, Sendable
+where Base: Sendable {
+ public typealias Element = Base.Element
+ public typealias AsyncIterator = Iterator
+
+ let base: Base
+ let onEach: @Sendable (Element) async -> Void
+
+ init(_ base: Base, onEach: @Sendable @escaping (Element) async -> Void) {
+ self.base = base
+ self.onEach = onEach
+ }
+
+ public func makeAsyncIterator() -> Iterator {
+ Iterator(
+ baseIterator: self.base.makeAsyncIterator(),
+ onEach: self.onEach
+ )
+ }
+
+ public struct Iterator: AsyncIteratorProtocol {
+ var baseIterator: Base.AsyncIterator
+ let onEach: @Sendable (Element) async -> Void
+
+ public mutating func next() async rethrows -> Element? {
+ let element = try await self.baseIterator.next()
+
+ if let element = element {
+ await self.onEach(element)
+ }
+
+ return element
+ }
+ }
+}
diff --git a/Sources/Supporting/AsyncSerialSequence.swift b/Sources/Supporting/AsyncSerialSequence.swift
new file mode 100644
index 0000000..eedb0c6
--- /dev/null
+++ b/Sources/Supporting/AsyncSerialSequence.swift
@@ -0,0 +1,154 @@
+//
+// AsyncSerialMapSequence.swift
+//
+//
+// Created by Thibault WITTEMBERG on 01/08/2022.
+//
+
+extension AsyncSequence {
+ func serial() -> AsyncSerialSequence {
+ AsyncSerialSequence(base: self)
+ }
+}
+
+public final class AsyncSerialSequence: AsyncSequence, Sendable
+where Base: Sendable{
+ public typealias Element = Base.Element
+ public typealias AsyncIterator = Iterator
+
+ struct Token: Hashable {
+ let id: Int
+ let continuation: UnsafeContinuation?
+
+ static func placeHolder(id: Int) -> Token {
+ Token(id: id, continuation: nil)
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(self.id)
+ }
+
+ static func ==(lhs: Token, rhs: Token) -> Bool {
+ lhs.id == rhs.id
+ }
+ }
+
+ enum State: Equatable {
+ case unlocked
+ case locked(Set)
+ }
+
+ let base: Base
+ let state: ManagedCriticalState
+ let ids: ManagedCriticalState
+
+ init(base: Base) {
+ self.base = base
+ self.state = ManagedCriticalState(.unlocked)
+ self.ids = ManagedCriticalState(0)
+ }
+
+ func generateId() -> Int {
+ self.ids.withCriticalRegion { ids -> Int in
+ ids += 1
+ return ids
+ }
+ }
+
+ func next(
+ _ base: inout Base.AsyncIterator,
+ onImmediateResume: (() -> Void)? = nil,
+ onSuspend: (() -> Void)? = nil
+ ) async rethrows -> Element? {
+
+ let tokenId = self.generateId()
+ let isCancelled = ManagedCriticalState(false)
+
+ return try await withTaskCancellationHandler {
+ let continuation = self.state.withCriticalRegion { state -> UnsafeContinuation? in
+ let continuation: UnsafeContinuation?
+
+ switch state {
+ case .unlocked:
+ continuation = nil
+ case .locked(var tokens):
+ if tokens.isEmpty {
+ state = .unlocked
+ continuation = nil
+ } else {
+ let removed = tokens.remove(.placeHolder(id: tokenId))
+ state = .locked(tokens)
+ continuation = removed?.continuation
+ }
+ }
+
+ isCancelled.apply(criticalState: true)
+
+ return continuation
+ }
+
+ continuation?.resume()
+ } operation: {
+ await withUnsafeContinuation { [state] (continuation: UnsafeContinuation) in
+ let continuation = state.withCriticalRegion { state -> UnsafeContinuation? in
+ guard !isCancelled.criticalState else { return continuation }
+
+ switch state {
+ case .unlocked:
+ state = .locked([])
+ return continuation
+ case .locked(var continuations):
+ continuations.update(with: Token(id: tokenId, continuation: continuation))
+ state = .locked(continuations)
+ return nil
+ }
+ }
+
+ if let continuation = continuation {
+ continuation.resume()
+ onImmediateResume?()
+ } else {
+ onSuspend?()
+ }
+ }
+
+ let element = try await base.next()
+
+ let continuation = self.state.withCriticalRegion { state -> UnsafeContinuation? in
+ switch state {
+ case .unlocked:
+ return nil
+ case .locked(var tokens):
+ if tokens.isEmpty {
+ state = .unlocked
+ return nil
+ } else {
+ let token = tokens.removeFirst()
+ state = .locked(tokens)
+ return token.continuation
+ }
+ }
+ }
+
+ continuation?.resume()
+
+ return element
+ }
+ }
+
+ public func makeAsyncIterator() -> Iterator {
+ return Iterator(
+ asyncSerialSequence: self,
+ baseIterator: self.base.makeAsyncIterator()
+ )
+ }
+
+ public struct Iterator: AsyncIteratorProtocol {
+ let asyncSerialSequence: AsyncSerialSequence
+ var baseIterator: Base.AsyncIterator
+
+ public mutating func next() async rethrows -> Element? {
+ try await self.asyncSerialSequence.next(&baseIterator)
+ }
+ }
+}
diff --git a/Sources/Supporting/Inject.swift b/Sources/Supporting/Inject.swift
new file mode 100644
index 0000000..8ac7ee1
--- /dev/null
+++ b/Sources/Supporting/Inject.swift
@@ -0,0 +1,130 @@
+//
+// Inject.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+public func inject(
+ dep a: A,
+ in block: @escaping (A) async -> R
+) -> () async -> R {
+ {
+ await block(a)
+ }
+}
+
+public func inject(
+ deps a: A,
+ _ b: B,
+ in block: @escaping (A, B) async -> R
+) -> () async -> R {
+ {
+ await block(a, b)
+ }
+}
+
+public func inject(
+ deps a: A,
+ _ b: B,
+ _ c: C,
+ in block: @escaping (A, B, C) async -> R
+) -> () async -> R {
+ {
+ await block(a, b, c)
+ }
+}
+
+public func inject(
+ deps a: A,
+ _ b: B,
+ _ c: C,
+ _ d: D,
+ in block: @escaping (A, B, C, D) async -> R
+) -> () async -> R {
+ {
+ await block(a, b, c, d)
+ }
+}
+
+public func inject(
+ deps a: A,
+ _ b: B,
+ _ c: C,
+ _ d: D,
+ _ e: E,
+ in block: @escaping (A, B, C, D, E) async -> R
+) -> () async -> R {
+ {
+ await block(a, b, c, d, e)
+ }
+}
+
+public func inject(
+ deps a: A,
+ _ b: B,
+ _ c: C,
+ _ d: D,
+ _ e: E,
+ _ f: F,
+ in block: @escaping (A, B, C, D, E, F) async -> R
+) -> () async -> R {
+ {
+ await block(a, b, c, d, e, f)
+ }
+}
+
+public func inject(
+ dep b: B,
+ in block: @escaping (A, B) async -> R
+) -> (A) async -> R {
+ { a in
+ await block(a, b)
+ }
+}
+
+public func inject(
+ deps b: B,
+ _ c: C,
+ in block: @escaping (A, B, C) async -> R
+) -> (A) async -> R {
+ { a in
+ await block(a, b, c)
+ }
+}
+
+public func inject(
+ deps b: B,
+ _ c: C,
+ _ d: D,
+ in block: @escaping (A, B, C, D) async -> R
+) -> (A) async -> R {
+ { a in
+ await block(a, b, c, d)
+ }
+}
+
+public func inject(
+ deps b: B,
+ _ c: C,
+ _ d: D,
+ _ e: E,
+ in block: @escaping (A, B, C, D, E) async -> R
+) -> (A) async -> R {
+ { a in
+ await block(a, b, c, d, e)
+ }
+}
+
+public func inject(
+ deps b: B,
+ _ c: C,
+ _ d: D,
+ _ e: E,
+ _ f: F,
+ in block: @escaping (A, B, C, D, E, F) async -> R
+) -> (A) async -> R {
+ { a in
+ await block(a, b, c, d, e, f)
+ }
+}
diff --git a/Sources/Supporting/ManagedCriticalState.swift b/Sources/Supporting/ManagedCriticalState.swift
new file mode 100644
index 0000000..102b7d0
--- /dev/null
+++ b/Sources/Supporting/ManagedCriticalState.swift
@@ -0,0 +1,45 @@
+import Darwin
+
+final class LockedBuffer: ManagedBuffer {
+ deinit {
+ _ = self.withUnsafeMutablePointerToElements { lock in
+ lock.deinitialize(count: 1)
+ }
+ }
+}
+
+struct ManagedCriticalState {
+ let buffer: ManagedBuffer
+
+ init(_ initial: State) {
+ buffer = LockedBuffer.create(minimumCapacity: 1) { buffer in
+ buffer.withUnsafeMutablePointerToElements { lock in
+ lock.initialize(to: os_unfair_lock())
+ }
+ return initial
+ }
+ }
+
+ @discardableResult
+ func withCriticalRegion(
+ _ critical: (inout State) throws -> R
+ ) rethrows -> R {
+ try buffer.withUnsafeMutablePointers { header, lock in
+ os_unfair_lock_lock(lock)
+ defer { os_unfair_lock_unlock(lock) }
+ return try critical(&header.pointee)
+ }
+ }
+
+ func apply(criticalState newState: State) {
+ self.withCriticalRegion { actual in
+ actual = newState
+ }
+ }
+
+ var criticalState: State {
+ self.withCriticalRegion { $0 }
+ }
+}
+
+extension ManagedCriticalState: @unchecked Sendable where State: Sendable { }
diff --git a/Sources/Supporting/OrderedStorage.swift b/Sources/Supporting/OrderedStorage.swift
new file mode 100644
index 0000000..10cf7c1
--- /dev/null
+++ b/Sources/Supporting/OrderedStorage.swift
@@ -0,0 +1,52 @@
+//
+// OrderedStorage.swift
+//
+//
+// Created by Thibault WITTEMBERG on 26/06/2022.
+//
+
+struct OrderedStorage {
+ var index = 0
+ var storage = [Int: Value]()
+
+ init() {}
+
+ init(contentOf array: [Value]) {
+ array.forEach { value in
+ self.append(value)
+ }
+ }
+
+ @discardableResult
+ mutating func append(_ value: Value) -> Int {
+ let currentIndex = self.index
+ self.storage[currentIndex] = value
+ self.index += 1
+ return currentIndex
+ }
+
+ mutating func removeAll() {
+ self.storage.removeAll()
+ }
+
+ mutating func remove(index: Int) {
+ self.storage[index] = nil
+ }
+
+ var indexedValues: [(index: Int, value: Value)] {
+ self
+ .storage
+ .sorted(by: { $0.0 < $1.0 })
+ .map { (index: $0.key, value: $0.value) }
+ }
+
+ var values: [Value] {
+ self
+ .indexedValues
+ .map { $0.value }
+ }
+
+ var count: Int {
+ self.storage.count
+ }
+}
diff --git a/Sources/ViewState.swift b/Sources/ViewState.swift
new file mode 100644
index 0000000..dddeb7a
--- /dev/null
+++ b/Sources/ViewState.swift
@@ -0,0 +1,114 @@
+//
+// ViewState.swift
+//
+//
+// Created by Thibault WITTEMBERG on 02/07/2022.
+//
+
+public class ViewState: ObservableObject
+where S: DSLCompatible & Equatable, E: DSLCompatible, O: DSLCompatible {
+ @Published public var state: S
+
+ let asyncStateMachineSequence: AsyncStateMachineSequence
+
+ public init(asyncStateMachineSequence: AsyncStateMachineSequence) {
+ self.asyncStateMachineSequence = asyncStateMachineSequence
+ self.state = self.asyncStateMachineSequence.initialState
+ }
+
+ public func send(_ event: E) {
+ self.asyncStateMachineSequence.send(event)
+ }
+
+ public func send(
+ _ event: E,
+ resumeWhen predicate: @escaping (S) -> Bool
+ ) async {
+ await withUnsafeContinuation { [asyncStateMachineSequence] (continuation: UnsafeContinuation) in
+ Task {
+ await asyncStateMachineSequence.engine.register(onTheFly: { state in
+ if predicate(state) {
+ continuation.resume()
+ // middleware will be unregistered after the predicate has been matched
+ return true
+ }
+ return false
+ })
+
+ asyncStateMachineSequence.send(event)
+ }
+ }
+ }
+
+ public func send(
+ _ event: E,
+ resumeWhen state: S
+ ) async {
+ await self.send(
+ event,
+ resumeWhen: { inputState in inputState.matches(state) }
+ )
+ }
+
+ public func send(
+ _ event: E,
+ resumeWhen state: @escaping (StateAssociatedValue) -> S
+ ) async {
+ await self.send(
+ event,
+ resumeWhen: { inputState in inputState.matches(state) }
+ )
+ }
+
+ public func send(
+ _ event: E,
+ resumeWhen states: OneOf
+ ) async {
+ await self.send(
+ event,
+ resumeWhen: { inputState in states.predicate(inputState) }
+ )
+ }
+
+ @MainActor func publish(state: S) {
+ if state != self.state {
+ self.state = state
+ }
+ }
+
+ nonisolated public func start() async {
+ for await state in self.asyncStateMachineSequence {
+ await self.publish(state: state)
+ }
+ }
+}
+
+#if canImport(SwiftUI)
+import SwiftUI
+
+public extension ViewState {
+ func binding(send event: @escaping (S) -> E) -> Binding {
+ Binding {
+ self.state
+ } set: { [asyncStateMachineSequence] value in
+ asyncStateMachineSequence.send(event(value))
+ }
+ }
+
+ func binding(send event: E) -> Binding {
+ self.binding(send: { _ in event })
+ }
+
+ func binding(keypath: KeyPath, send event: @escaping (T) -> E) -> Binding {
+ Binding {
+ self.state[keyPath: keypath]
+ } set: { [asyncStateMachineSequence] value in
+ asyncStateMachineSequence.send(event(value))
+ }
+ }
+
+ func binding(keypath: KeyPath, send event: E) -> Binding {
+ self.binding(keypath: keypath, send: { _ in event })
+ }
+}
+#endif
diff --git a/Sources/XCTest/XCTStateMachine.swift b/Sources/XCTest/XCTStateMachine.swift
new file mode 100644
index 0000000..97d167e
--- /dev/null
+++ b/Sources/XCTest/XCTStateMachine.swift
@@ -0,0 +1,108 @@
+//
+// XCTStateMachine.swift
+//
+//
+// Created by Thibault WITTEMBERG on 25/06/2022.
+//
+
+#if DEBUG
+import XCTestDynamicOverlay
+
+public class XCTStateMachine
+where
+S: DSLCompatible & Equatable,
+E: DSLCompatible & Equatable,
+O: DSLCompatible & Equatable {
+ let stateMachine: StateMachine
+
+ public init(_ stateMachine: StateMachine) {
+ self.stateMachine = stateMachine
+ }
+
+ @discardableResult
+ public func assert(
+ when states: S...,
+ on event: E,
+ transitionTo expectedState: S,
+ fail: (String) -> Void = XCTFail
+ ) async -> Self {
+ for state in states {
+ let receivedState = await self.stateMachine.reduce(when: state, on: event)
+ guard receivedState == expectedState else {
+ fail(
+ """
+ The assertion failed for state \(state) and event \(event):
+ expected new state: \(expectedState),
+ received new state: \(String(describing: receivedState))
+ """
+ )
+ return self
+ }
+ }
+ return self
+ }
+
+ @discardableResult
+ public func assertNoTransition(
+ when states: S...,
+ on event: E,
+ fail: (String) -> Void = XCTFail
+ ) async -> Self {
+ for state in states {
+ if let receivedState = await self.stateMachine.reduce(when: state, on: event) {
+ fail(
+ """
+ The assertion failed for state \(state) and event \(event):
+ expected no new state,
+ received new state: \(receivedState)
+ """
+ )
+ return self
+ }
+ }
+ return self
+ }
+
+ @discardableResult
+ public func assert(
+ when states: S...,
+ execute expectedOutput: O,
+ fail: (String) -> Void = XCTFail
+ ) -> Self {
+ for state in states {
+ let receivedOutput = self.stateMachine.output(for: state)
+ guard receivedOutput == expectedOutput else {
+ fail(
+ """
+ The assertion failed for state \(state):
+ expected output: \(expectedOutput),
+ received output: \(String(describing: receivedOutput))
+ """
+ )
+ return self
+ }
+ }
+ return self
+ }
+
+ @discardableResult
+ public func assertNoOutput(
+ when states: S...,
+ fail: (String) -> Void = XCTFail
+ ) -> Self {
+ for state in states {
+ if let receivedOutput = self.stateMachine.output(for: state) {
+ fail(
+ """
+ The assertion failed for state \(state):
+ expected no output,
+ received output: \(receivedOutput)
+ """
+ )
+ return self
+ }
+ }
+ return self
+ }
+}
+#endif
diff --git a/Tests/AsyncStateMachineSequenceTests.swift b/Tests/AsyncStateMachineSequenceTests.swift
new file mode 100644
index 0000000..2f4d8e7
--- /dev/null
+++ b/Tests/AsyncStateMachineSequenceTests.swift
@@ -0,0 +1,385 @@
+@testable import AsyncStateMachine
+import XCTest
+
+enum State: DSLCompatible, Equatable {
+ case s1(value: String)
+ case s2(value: String)
+ case s3(value: String)
+ case s4(value: String)
+ case s5(value: String)
+ case s6(value: String)
+ case s7(value: String)
+ case s8(value: String)
+ case s9(value: String)
+ case s10(value: String)
+ case s11(value: String)
+ case s12(value: String)
+
+ var value: String {
+ switch self {
+ case .s1(let value): return value
+ case .s2(let value): return value
+ case .s3(let value): return value
+ case .s4(let value): return value
+ case .s5(let value): return value
+ case .s6(let value): return value
+ case .s7(let value): return value
+ case .s8(let value): return value
+ case .s9(let value): return value
+ case .s10(let value): return value
+ case .s11(let value): return value
+ case .s12(let value): return value
+ }
+ }
+}
+
+enum Event: DSLCompatible, Equatable {
+ case e1(value: String)
+ case e2(value: String)
+ case e3(value: String)
+ case e4(value: String)
+ case e5(value: String)
+ case e6(value: String)
+ case e7(value: String)
+ case e8(value: String)
+ case e9(value: String)
+ case e10(value: String)
+ case e11(value: String)
+ case e12(value: String)
+
+ var value: String {
+ switch self {
+ case .e1(let value): return value
+ case .e2(let value): return value
+ case .e3(let value): return value
+ case .e4(let value): return value
+ case .e5(let value): return value
+ case .e6(let value): return value
+ case .e7(let value): return value
+ case .e8(let value): return value
+ case .e9(let value): return value
+ case .e10(let value): return value
+ case .e11(let value): return value
+ case .e12(let value): return value
+ }
+ }
+}
+
+enum Output: DSLCompatible, Equatable {
+ case o1(value: String)
+ case o2(value: String)
+ case o3(value: String)
+}
+
+let stateMachine = StateMachine(initial: State.s1(value: "s1")) {
+ When(states: OneOf {
+ State.s1(value:)
+ State.s2(value:)
+ State.s3(value:)
+ State.s4(value:)
+ State.s5(value:)
+ State.s6(value:)
+ State.s7(value:)
+ State.s8(value:)
+ State.s9(value:)
+ State.s10(value:)
+ State.s11(value:)
+ State.s12(value:)
+ }) { state in
+ Execute(output: Output.o1(value: state.value))
+ } transitions: { state in
+ On(events: OneOf {
+ Event.e1(value:)
+ Event.e2(value:)
+ Event.e3(value:)
+ Event.e4(value:)
+ Event.e5(value:)
+ Event.e6(value:)
+ Event.e7(value:)
+ Event.e8(value:)
+ Event.e9(value:)
+ Event.e10(value:)
+ Event.e11(value:)
+ Event.e12(value:)
+ }) { _ in
+ Guard(predicate: !state.value.isEmpty)
+ } transition: { event in
+ Transition(to: State.s2(value: state.value + event.value))
+ }
+ }
+}
+
+final class AsyncStateMachineSequenceTests: XCTestCase {
+// func testPerformance() async {
+// measure {
+// let exp = expectation(description: "task")
+// let task = Task {
+// for s in (1...12) {
+// for e in (1...12) {
+// _ = await stateMachine.reduce(when: State.s1(value: "s\(s)"), on: Event.e1(value: "e\(e)"))
+// }
+// }
+// exp.fulfill()
+// }
+// wait(for: [exp], timeout: 10.0)
+// task.cancel()
+// }
+// }
+
+ func test_states_and_events_match_the_expected_flow() async {
+ let allReceived = expectation(description: "Events and States have been received")
+ allReceived.expectedFulfillmentCount = 3
+
+ // Given
+ let stateMachine = StateMachine(initial: State.s1(value: "value")) {
+ When(state: State.s1(value:)) { stateValue in
+ Execute(output: Output.o1(value: stateValue))
+ } transitions: { stateValue in
+
+ On(event: Event.e1(value:)) { eventValue in
+ Guard(predicate: stateValue.isEmpty || eventValue.isEmpty)
+ } transition: { eventValue in
+ Transition(to: State.s2(value: "new value"))
+ }
+
+ On(event: Event.e2(value:)) { eventValue in
+ Guard(predicate: !stateValue.isEmpty && !eventValue.isEmpty)
+ } transition: { eventValue in
+ Transition(to: State.s3(value: eventValue))
+ }
+ }
+
+ When(state: State.s2(value:)) { stateValue in
+ Execute(output: Output.o2(value: stateValue))
+ } transitions: { stateValue in
+ On(event: Event.e3(value:)) { eventValue in
+ Guard(predicate: stateValue.isEmpty || eventValue.isEmpty)
+ } transition: { eventValue in
+ Transition(to: State.s4(value: eventValue))
+ }
+
+ On(event: Event.e4(value:)) { eventValue in
+ Guard(predicate: !stateValue.isEmpty && !eventValue.isEmpty)
+ } transition: { eventValue in
+ Transition(to: State.s5(value: eventValue))
+ }
+ }
+
+ When(states: OneOf {
+ State.s4(value:)
+ State.s5(value:)
+ }) { state in
+ Execute(output: Output.o3(value: state.value))
+ } transitions: { state in
+ On(event: Event.e5(value:)) { eventValue in
+ Guard(predicate: state.value.isEmpty || eventValue.isEmpty)
+ } transition: { eventValue in
+ Transition(to: State.s6(value: eventValue))
+ }
+
+ On(event: Event.e6(value:)) { eventValue in
+ Guard(predicate: !state.value.isEmpty && !eventValue.isEmpty)
+ } transition: { eventValue in
+ Transition(to: State.s7(value: eventValue))
+ }
+ }
+ }
+
+ let receivedStatesInSequence = ManagedCriticalState<[State]>([])
+ let receivedStatesInMiddleware = ManagedCriticalState<[State]>([])
+ let receivedEventsInMiddleware = ManagedCriticalState<[Event]>([])
+
+ let runtime = Runtime()
+ .map(output: Output.o1(value:), to: { _ in Event.e1(value: "") })
+ .map(output: Output.o2(value:), to: { outputValue in Event.e4(value: outputValue) })
+ .map(output: Output.o3(value:), to: { outputValue in Event.e6(value: outputValue) })
+ .register(middleware: { (state: State) in
+ receivedStatesInMiddleware.withCriticalRegion { $0.append(state) }
+ if state == State.s7(value: "new value") {
+ allReceived.fulfill()
+ }
+ })
+ .register(middleware: { (event: Event) in
+ receivedEventsInMiddleware.withCriticalRegion { $0.append(event) }
+ if event == Event.e6(value: "new value") {
+ allReceived.fulfill()
+ }
+ })
+
+ let sequence = AsyncStateMachineSequence(stateMachine: stateMachine, runtime: runtime)
+
+ // When
+ Task {
+ for await state in sequence {
+ receivedStatesInSequence.withCriticalRegion { $0.append(state) }
+ if state == State.s7(value: "new value") {
+ allReceived.fulfill()
+ }
+ }
+ }
+
+ wait(for: [allReceived], timeout: 5.0)
+
+ // Then
+ let expectedStates = [
+ State.s1(value: "value"),
+ State.s2(value: "new value"),
+ State.s5(value: "new value"),
+ State.s7(value: "new value")
+ ]
+
+ let expectedEvents = [
+ Event.e1(value: ""),
+ Event.e4(value: "new value"),
+ Event.e6(value: "new value"),
+ ]
+
+ XCTAssertEqual(receivedStatesInSequence.criticalState, expectedStates)
+ XCTAssertEqual(receivedStatesInMiddleware.criticalState, expectedStates)
+ XCTAssertEqual(receivedEventsInMiddleware.criticalState, expectedEvents)
+ }
+
+ func test_channel_connects_sender_and_receiver() {
+ // Given
+ let channel = Channel()
+
+ let stateMachine1 = StateMachine(initial: State.s1(value: "value")) {
+ When(state: State.s1(value:)) { _ in
+ Execute.noOutput
+ } transitions: { _ in
+ On(event: Event.e1(value:)) { _ in
+ Transition(to: State.s2(value: "value2"))
+ }
+ }
+ }
+
+ let receivedValue = ManagedCriticalState(nil)
+
+ let runtime1 = Runtime()
+ .connectAsSender(to: channel, when: State.s2(value:), send: { value in
+ receivedValue.apply(criticalState: value)
+ return Event.e1(value: value)
+ })
+
+ let stateMachine2 = StateMachine(initial: State.s1(value: "value")) {
+ When(state: State.s1(value:)) { _ in
+ Execute.noOutput
+ } transitions: { _ in
+ On(event: Event.e1(value:)) { _ in
+ Transition(to: State.s2(value: "value2"))
+ }
+ }
+ }
+
+ let runtime2 = Runtime()
+ .connectAsReceiver(to: channel)
+
+ let asyncStateMachineSequence1 = AsyncStateMachineSequence(stateMachine: stateMachine1, runtime: runtime1)
+ let asyncStateMachineSequence2 = AsyncStateMachineSequence(stateMachine: stateMachine2, runtime: runtime2)
+
+ let firstStatesHaveBeenEmitted = expectation(description: "The first states have been emitted")
+ firstStatesHaveBeenEmitted.expectedFulfillmentCount = 2
+
+ let secondStatesHaveBeenEmitted = expectation(description: "The second states have been emitted")
+ secondStatesHaveBeenEmitted.expectedFulfillmentCount = 2
+
+ Task {
+ for await state in asyncStateMachineSequence1 {
+ if state == .s1(value: "value") {
+ firstStatesHaveBeenEmitted.fulfill()
+ }
+
+ if state == .s2(value: "value2") {
+ secondStatesHaveBeenEmitted.fulfill()
+ }
+ }
+ }
+
+ Task {
+ for await state in asyncStateMachineSequence2 {
+ if state == .s1(value: "value") {
+ firstStatesHaveBeenEmitted.fulfill()
+ }
+
+ if state == .s2(value: "value2") {
+ secondStatesHaveBeenEmitted.fulfill()
+ }
+ }
+ }
+
+ wait(for: [firstStatesHaveBeenEmitted], timeout: 1.0)
+
+ // When
+ asyncStateMachineSequence1.send(.e1(value: "value"))
+
+ // Then
+ wait(for: [secondStatesHaveBeenEmitted], timeout: 1.0)
+
+ XCTAssertEqual(receivedValue.criticalState, "value2")
+ }
+
+ func test_deinit_finishes_the_eventChannel() {
+ let asyncStateMachineSequenceIsDeinit = expectation(description: "The AasyncStateMachineSequence has been deinit")
+ let eventChannelIsFinished = expectation(description: "The underlying eventChannel has been finished")
+
+ let stateMachine = StateMachine(initial: State.s1(value: "value")) {}
+ let runtime = Runtime()
+
+ // Given
+ var sut: AsyncStateMachineSequence? = AsyncStateMachineSequence(
+ stateMachine: stateMachine,
+ runtime: runtime,
+ onDeinit: { asyncStateMachineSequenceIsDeinit.fulfill() }
+ )
+
+ let eventChannel = sut!.eventChannel
+
+ Task {
+ for await _ in eventChannel {}
+ eventChannelIsFinished.fulfill()
+ }
+
+ // When
+ sut = nil
+
+ // Then
+ wait(for: [asyncStateMachineSequenceIsDeinit, eventChannelIsFinished], timeout: 1.0)
+ XCTAssertNil(sut)
+ }
+
+ func test_deinit_unregisters_asyncStateMachineSequence_from_channel_receivers() {
+ let asyncStateMachineSequenceIsDeinit = expectation(description: "on deinit")
+
+ let channel1 = Channel()
+ let channel2 = Channel()
+
+ // Given
+ let stateMachine = StateMachine(initial: State.s1(value: "value")) {}
+
+ let runtime = Runtime()
+ .connectAsReceiver(to: channel1)
+ .connectAsReceiver(to: channel2)
+
+ var sut: AsyncStateMachineSequence? = AsyncStateMachineSequence(
+ stateMachine: stateMachine,
+ runtime: runtime,
+ onDeinit: { asyncStateMachineSequenceIsDeinit.fulfill() }
+ )
+
+ runtime.channelReceivers.forEach { receiver in
+ XCTAssertNotNil(receiver.receiver.criticalState)
+ }
+
+ // When
+ sut = nil
+
+ // Then
+ wait(for: [asyncStateMachineSequenceIsDeinit], timeout: 1.0)
+
+ runtime.channelReceivers.forEach { receiver in
+ XCTAssertNil(receiver.receiver.criticalState)
+ }
+
+ XCTAssertNil(sut)
+ }
+}
diff --git a/Tests/EngineTests.swift b/Tests/EngineTests.swift
new file mode 100644
index 0000000..9ff544f
--- /dev/null
+++ b/Tests/EngineTests.swift
@@ -0,0 +1,687 @@
+//
+// EngineTests.swift
+//
+//
+// Created by Thibault WITTEMBERG on 20/06/2022.
+//
+
+@testable import AsyncStateMachine
+import XCTest
+
+final class EngineTests: XCTestCase {
+ enum State: DSLCompatible, Equatable {
+ case s1
+ case s2(value: String)
+ case s3
+ case s4(value: Int)
+ }
+
+ enum Event: DSLCompatible, Equatable {
+ case e1
+ case e2(value: String)
+ case e3
+ case e4
+ }
+
+ enum Output: DSLCompatible, Equatable {
+ case o1
+ case o2
+ }
+
+ func test_init_sets_all_the_properties_when_called_with_functions() async throws {
+ let resolvedOutputIsCalled = ManagedCriticalState(false)
+ let computeNextStateIsCalled = ManagedCriticalState(false)
+ let resolveSideEffectIsCalled = ManagedCriticalState(false)
+ let eventMiddlewareIsCalled = ManagedCriticalState(false)
+ let stateMiddlewareIsCalled = ManagedCriticalState(false)
+
+ let eventMiddleware = Middleware(execute: { _ in eventMiddlewareIsCalled.apply(criticalState: true); return false }, priority: nil)
+ let stateMiddleware = Middleware(execute: { _ in stateMiddlewareIsCalled.apply(criticalState: true); return false }, priority: nil)
+
+ // Given
+ let sut = Engine(
+ resolveOutput: { _ in resolvedOutputIsCalled.apply(criticalState: true); return nil },
+ computeNextState: { _, _ in computeNextStateIsCalled.apply(criticalState: true); return nil},
+ resolveSideEffect: { _ in resolveSideEffectIsCalled.apply(criticalState: true); return nil },
+ eventMiddlewares: [eventMiddleware],
+ stateMiddlewares: [stateMiddleware]
+ )
+
+ // When
+ _ = await sut.resolveOutput(State.s1)
+ // Then
+ XCTAssertTrue(resolvedOutputIsCalled.criticalState)
+
+ // When
+ _ = await sut.computeNextState(State.s1, Event.e1)
+ XCTAssertTrue(computeNextStateIsCalled.criticalState)
+
+ // When
+ _ = await sut.resolveSideEffect(Output.o1)
+ // Then
+ XCTAssertTrue(resolveSideEffectIsCalled.criticalState)
+
+ // When
+ let stateMiddlewaresCount = await sut.stateMiddlewares.count
+ // Then
+ XCTAssertEqual(stateMiddlewaresCount, 1)
+
+ // When
+ _ = await sut.stateMiddlewares.values.first?.execute(State.s1)
+ // Then
+ XCTAssertTrue(stateMiddlewareIsCalled.criticalState)
+
+ // When
+ let eventMiddlewaresCount = await sut.eventMiddlewares.count
+ // Then
+ XCTAssertEqual(eventMiddlewaresCount, 1)
+
+ // When
+ _ = await sut.eventMiddlewares.values.first?.execute(Event.e1)
+ // Then
+ XCTAssertTrue(eventMiddlewareIsCalled.criticalState)
+
+ // When
+ let tasksInProgressCount = await sut.tasksInProgress.count
+ // Then
+ XCTAssertEqual(tasksInProgressCount, 0)
+ }
+
+ func test_register_adds_a_task_in_progress_and_removes_when_finished() async {
+ let taskInProgressHasBeenAdded = expectation(description: "The task has been added to the tasks in progress")
+
+ // Given
+ let sut = Engine(
+ resolveOutput: { _ in nil },
+ computeNextState: { _, _ in nil},
+ resolveSideEffect: { _ in nil },
+ eventMiddlewares: [],
+ stateMiddlewares: []
+ )
+
+ let task = Task {
+ wait(for: [taskInProgressHasBeenAdded], timeout: 10.0)
+ }
+
+ // When
+ let removeTask = await sut.register(taskInProgress: task, cancelOn: { _ in true })
+
+ // Then
+ let tasksInProgressCountBefore = await sut.tasksInProgress.count
+ XCTAssertEqual(tasksInProgressCountBefore, 1)
+
+ taskInProgressHasBeenAdded.fulfill()
+
+ await removeTask.value
+
+ let tasksInProgressCountAfter = await sut.tasksInProgress.count
+ XCTAssertEqual(tasksInProgressCountAfter, 0)
+ }
+
+ func test_register_adds_a_task_in_progress_that_can_cancel() async {
+ let taskHasStarted = expectation(description: "The task has started")
+
+ let receivedState = ManagedCriticalState(nil)
+
+ // Given
+ let sut = Engine(
+ resolveOutput: { _ in nil },
+ computeNextState: { _, _ in nil},
+ resolveSideEffect: { _ in nil },
+ eventMiddlewares: [],
+ stateMiddlewares: []
+ )
+
+ let task = Task.forEver {
+ taskHasStarted.fulfill()
+ } onCancel: {
+ }
+
+
+ // When
+ await sut.register(taskInProgress: task, cancelOn: { state in
+ receivedState.apply(criticalState: state)
+ return true
+ })
+
+ wait(for: [taskHasStarted], timeout: 10.0)
+
+ // Then
+ let tasksInProgress = await sut.tasksInProgress.values
+ let receivedShouldCancel = tasksInProgress.first?.cancellationPredicate(State.s1)
+
+ XCTAssertTrue(receivedShouldCancel!)
+ XCTAssertEqual(receivedState.criticalState, State.s1)
+
+ task.cancel()
+ }
+
+ func test_cancelTaskInProgress_cancels_the_expected_task_when_called() async {
+ let tasksHaveStarted = expectation(description: "Tasks have started")
+ tasksHaveStarted.expectedFulfillmentCount = 2
+
+ let taskAHasBeenCancelled = expectation(description: "The task A has been cancelled")
+ let taskBHasBeenCancelled = ManagedCriticalState(false)
+
+ // Given
+ let sut = Engine(
+ resolveOutput: { _ in nil },
+ computeNextState: { _, _ in nil},
+ resolveSideEffect: { _ in nil },
+ eventMiddlewares: [],
+ stateMiddlewares: []
+ )
+
+ let taskToCancel = Task.forEver {
+ tasksHaveStarted.fulfill()
+ } onCancel: {
+ taskAHasBeenCancelled.fulfill()
+ }
+
+ let taskToRemain = Task.forEver {
+ tasksHaveStarted.fulfill()
+ } onCancel: {
+ taskBHasBeenCancelled.apply(criticalState: true)
+ }
+
+ await sut.register(taskInProgress: taskToCancel, cancelOn: { state in state == .s1 })
+ await sut.register(taskInProgress: taskToRemain, cancelOn: { state in state == .s3 })
+
+ wait(for: [tasksHaveStarted], timeout: 10.0)
+
+ // When
+ await sut.cancelTasksInProgress(for: State.s1)
+
+ wait(for: [taskAHasBeenCancelled], timeout: 0.5)
+ XCTAssertFalse(taskBHasBeenCancelled.criticalState)
+
+ let tasksInProgress = await sut.tasksInProgress.values
+ XCTAssertEqual(tasksInProgress.count, 1)
+
+ taskToRemain.cancel()
+ }
+
+ func test_cancelTasksInProgress_cancels_all_tasks_when_called() async {
+ let tasksHaveBeenCancelled = expectation(description: "The tasks have been cancelled")
+ tasksHaveBeenCancelled.expectedFulfillmentCount = 2
+
+ // Given
+ let sut = Engine(
+ resolveOutput: { _ in nil },
+ computeNextState: { _, _ in nil},
+ resolveSideEffect: { _ in nil },
+ eventMiddlewares: [],
+ stateMiddlewares: []
+ )
+
+ let taskToCancelA = Task.forEver {
+ } onCancel: {
+ tasksHaveBeenCancelled.fulfill()
+ }
+
+ let taskToCancelB = Task.forEver {
+ } onCancel: {
+ tasksHaveBeenCancelled.fulfill()
+ }
+
+ await sut.register(taskInProgress: taskToCancelA, cancelOn: { state in state == .s1 })
+ await sut.register(taskInProgress: taskToCancelB, cancelOn: { state in state == .s3 })
+
+ // When
+ await sut.cancelTasksInProgress()
+
+ wait(for: [tasksHaveBeenCancelled], timeout: 10.0)
+
+ let tasksInProgress = await sut.tasksInProgress.values
+ XCTAssertTrue(tasksInProgress.isEmpty)
+ }
+
+ func test_process_executes_event_middleware_when_called() async {
+ let middlewareACanFinish = expectation(description: "The middleware A can finish")
+ let middlewareBCanFinish = expectation(description: "The middleware B can finish")
+
+ let middlewaresHaveBeenCalled = expectation(description: "The middlewares have been called")
+ middlewaresHaveBeenCalled.expectedFulfillmentCount = 2
+
+ let receivedEventInMiddlewareA = ManagedCriticalState(nil)
+ let receivedEventInMiddlewareB = ManagedCriticalState(nil)
+
+ let receivedTaskPriorityInMiddlewareA = ManagedCriticalState(nil)
+ let receivedTaskPriorityInMiddlewareB = ManagedCriticalState(nil)
+
+ // Given
+ let middlewareA = Middleware(execute: { (event: Event) in
+ receivedEventInMiddlewareA.apply(criticalState: event)
+ receivedTaskPriorityInMiddlewareA.apply(criticalState: Task.currentPriority)
+
+ middlewaresHaveBeenCalled.fulfill()
+
+ self.wait(for: [middlewareACanFinish], timeout: 10.0)
+ return false
+ }, priority: .utility)
+
+ let middlewareB = Middleware(execute: { (event: Event) in
+ receivedEventInMiddlewareB.apply(criticalState: event)
+ receivedTaskPriorityInMiddlewareB.apply(criticalState: Task.currentPriority)
+
+ middlewaresHaveBeenCalled.fulfill()
+
+ self.wait(for: [middlewareBCanFinish], timeout: 10.0)
+ return true
+ }, priority: .high)
+
+ let sut = Engine(
+ resolveOutput: { _ in nil },
+ computeNextState: { _, _ in nil},
+ resolveSideEffect: { _ in nil },
+ eventMiddlewares: [middlewareA, middlewareB],
+ stateMiddlewares: []
+ )
+
+ // When
+ let removeTasksInProgressTasks = await sut.process(event: Event.e1)
+
+ wait(for: [middlewaresHaveBeenCalled], timeout: 10.0)
+
+ // Then
+ XCTAssertEqual(receivedEventInMiddlewareA.criticalState, Event.e1)
+ XCTAssertEqual(receivedEventInMiddlewareB.criticalState, Event.e1)
+
+ XCTAssertTrue(receivedTaskPriorityInMiddlewareA.criticalState.unsafelyUnwrapped >= .utility)
+ XCTAssertTrue(receivedTaskPriorityInMiddlewareB.criticalState.unsafelyUnwrapped >= .high)
+
+ var eventMiddlewares = await sut.eventMiddlewares.values
+ XCTAssertEqual(eventMiddlewares.count, 2)
+
+ var tasksInProgress = await sut.tasksInProgress.values
+ XCTAssertEqual(tasksInProgress.count, 2)
+
+ middlewareACanFinish.fulfill()
+ middlewareBCanFinish.fulfill()
+
+ // waiting for all tasks in progress (middlewares) to finish
+ for task in removeTasksInProgressTasks {
+ await task.value
+ }
+
+ eventMiddlewares = await sut.eventMiddlewares.values
+ XCTAssertEqual(eventMiddlewares.count, 1)
+
+ tasksInProgress = await sut.tasksInProgress.values
+ XCTAssertTrue(tasksInProgress.isEmpty)
+ }
+
+ func test_process_registers_non_cancellable_tasks_on_specific_state_when_called() async {
+ let middlewaresHaveStarted = expectation(description: "The middlewares have started")
+ middlewaresHaveStarted.expectedFulfillmentCount = 2
+
+ let middlewareAHasBeenCancelled = ManagedCriticalState(false)
+ let middlewareBHasBeenCancelled = ManagedCriticalState(false)
+
+ // Given
+ let middlewareA = Middleware(execute: { (state: State) in
+ await Task.forEver {
+ middlewaresHaveStarted.fulfill()
+ } onCancel: {
+ middlewareAHasBeenCancelled.apply(criticalState: true)
+ }.value
+
+ return false
+ }, priority: nil)
+
+ let middlewareB = Middleware(execute: { (state: State) in
+ await Task.forEver {
+ middlewaresHaveStarted.fulfill()
+ } onCancel: {
+ middlewareBHasBeenCancelled.apply(criticalState: true)
+ }.value
+
+ return false
+ }, priority: nil)
+
+ let sut = Engine(
+ resolveOutput: { _ in nil },
+ computeNextState: { _, _ in nil},
+ resolveSideEffect: { _ in nil },
+ eventMiddlewares: [],
+ stateMiddlewares: []
+ )
+
+ // When
+ await sut.process(middlewares: [(0, middlewareA), (1, middlewareB)], using: State.s1, removeMiddleware: { _ in })
+
+ wait(for: [middlewaresHaveStarted], timeout: 10.0)
+
+ // Then
+ var tasksInProgress = await sut.tasksInProgress.values
+ XCTAssertEqual(tasksInProgress.count, 2)
+
+ await sut.cancelTasksInProgress(for: State.s1)
+
+ tasksInProgress = await sut.tasksInProgress.values
+ XCTAssertEqual(tasksInProgress.count, 2)
+
+ await sut.cancelTasksInProgress()
+
+ tasksInProgress = await sut.tasksInProgress.values
+ XCTAssertTrue(tasksInProgress.isEmpty)
+ }
+
+ func test_process_state_cancels_all_eligible_tasks_when_called() async {
+ let tasksAreStarted = expectation(description: "All the tasks are started")
+ tasksAreStarted.expectedFulfillmentCount = 3
+
+ let tasksAreCancelled = expectation(description: "Elligible tasks are cancelled")
+ tasksAreCancelled.expectedFulfillmentCount = 2
+
+ let notElligibleTaskHasBeenCancelled = ManagedCriticalState(false)
+
+ // Given
+ let sut = Engine(
+ resolveOutput: { _ in nil },
+ computeNextState: { _, _ in nil},
+ resolveSideEffect: { _ in nil },
+ eventMiddlewares: [],
+ stateMiddlewares: []
+ )
+
+ let taskToCancelA = Task.forEver {
+ tasksAreStarted.fulfill()
+ } onCancel: {
+ tasksAreCancelled.fulfill()
+ }
+
+ let taskToNotCancel = Task.forEver {
+ tasksAreStarted.fulfill()
+ } onCancel: {
+ notElligibleTaskHasBeenCancelled.apply(criticalState: true)
+ }
+
+ let taskToCancelB = Task.forEver {
+ tasksAreStarted.fulfill()
+ } onCancel: {
+ tasksAreCancelled.fulfill()
+ }
+
+ await sut.register(taskInProgress: taskToCancelA, cancelOn: { $0 == .s3 })
+ await sut.register(taskInProgress: taskToNotCancel, cancelOn: { $0.matches(State.s2(value:)) })
+ await sut.register(taskInProgress: taskToCancelB, cancelOn: { $0 == .s3 })
+
+ wait(for: [tasksAreStarted], timeout: 10.0)
+
+ // When
+ await sut.process(state: State.s3, sendBackEvent: nil)
+
+ // Then
+ wait(for: [tasksAreCancelled], timeout: 10.0)
+ XCTAssertFalse(notElligibleTaskHasBeenCancelled.criticalState)
+
+ let tasksInProgress = await sut.tasksInProgress.values
+ XCTAssertEqual(tasksInProgress.count, 1)
+
+ taskToNotCancel.cancel()
+ }
+
+ func test_process_state_executes_state_middleware_when_called() async {
+ let middlewareACanFinish = expectation(description: "The middleware A can finish")
+ let middlewareBCanFinish = expectation(description: "The middleware B can finish")
+
+ let middlewaresHaveBeenCalled = expectation(description: "The middlewares have been called")
+ middlewaresHaveBeenCalled.expectedFulfillmentCount = 2
+
+ let receivedStateInMiddlewareA = ManagedCriticalState(nil)
+ let receivedStateInMiddlewareB = ManagedCriticalState