├── codecov.yml ├── .swiftlint.yml ├── Sources └── SwiftFSM │ ├── Internal │ ├── Syntax │ │ ├── .Ulysses-Group.plist │ │ ├── Override.swift │ │ ├── ResultBuilder.swift │ │ ├── SuperState.swift │ │ ├── Actions.swift │ │ ├── Then.swift │ │ ├── When.swift │ │ ├── CompoundSyntax.swift │ │ ├── Define.swift │ │ └── Matching.swift │ ├── Matching │ │ ├── Collection+Combinations.swift │ │ └── AnyPredicate.swift │ ├── Nodes │ │ ├── AnyTraceable.swift │ │ ├── SyntaxNode.swift │ │ ├── Validation │ │ │ ├── MatchResolvingNode │ │ │ │ ├── MRNBase.swift │ │ │ │ ├── LazyMRN.swift │ │ │ │ └── EagerMRN.swift │ │ │ └── SemanticValidationNode.swift │ │ ├── GivenNode.swift │ │ ├── ThenNode.swift │ │ ├── WhenNode.swift │ │ ├── ActionsNode.swift │ │ ├── MatchingNode.swift │ │ ├── ActionsResolvingNode.swift │ │ ├── NodeConvenience.swift │ │ └── DefineNode.swift │ └── FSM │ │ ├── AnyAction.swift │ │ ├── EagerFSM.swift │ │ ├── Logger.swift │ │ ├── LazyFSM.swift │ │ └── FSMBase.swift │ └── Public │ ├── FSMValue │ ├── FSMValue+Bool.swift │ ├── FSMValue+Nil.swift │ ├── FSMValue+Interpolation.swift │ ├── FSMValue+Equatable.swift │ ├── FSMValue+String.swift │ ├── FSMVaue+Dictionary.swift │ ├── FSMValue+Array.swift │ ├── FSMValue+Comparable.swift │ ├── FSMValue.swift │ └── FSMValue+Numbers.swift │ ├── OperatorSyntax │ ├── AnyActionOperators.swift │ └── PipeOperators.swift │ ├── FSM.swift │ └── FunctionSyntax │ └── ExpandedSyntaxBuilder.swift ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── swift-fsm.xcscheme ├── TestPlans └── SwiftFSMTests.xctestplan ├── Package.resolved ├── Tests └── SwiftFSMTests │ ├── Syntax │ ├── CompoundBlockSyntax │ │ ├── ThenBlockTests.swift │ │ ├── OverrideBlockTests.swift │ │ ├── WhenBlockTests.swift │ │ ├── SuperStateTests.swift │ │ ├── DefineTests.swift │ │ ├── BlockTestsBase.swift │ │ ├── BuilderTests.swift │ │ ├── ConditionBlockTests.swift │ │ └── MatchingBlockTests.swift │ ├── ResultBuilderTests.swift │ ├── AnyActionSyntaxTests.swift │ └── CompoundSyntaxTests.swift │ ├── Matching │ ├── CombinationsTests.swift │ └── PredicateTests.swift │ ├── Nodes │ ├── AnyTraceableTests.swift │ ├── AnyActionTests.swift │ ├── ActionsNodeTests.swift │ ├── MatchingNodeTests.swift │ ├── ThenNodeTests.swift │ ├── WhenNodeTests.swift │ ├── NodeTests.swift │ ├── GivenNodeTests.swift │ ├── MatchResolvingNode │ │ ├── MRNTestBase.swift │ │ ├── LazyMRNTests.swift │ │ └── EagerMRNTests.swift │ ├── ActionsResolvingNodeTests.swift │ └── DefineNodeTests.swift │ ├── ManualTests.swift │ ├── PublicAPITests.swift │ └── FSM │ └── LoggingTests.swift ├── LICENSE.txt ├── Package.swift └── .github └── workflows └── swift.yml /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - Tests/ 3 | coverage: 4 | status: 5 | project: off 6 | patch: off 7 | 8 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - type_name 3 | - force_cast 4 | - force_try 5 | - identifier_name 6 | - nesting 7 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/.Ulysses-Group.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drseg/swift-fsm/HEAD/Sources/SwiftFSM/Internal/Syntax/.Ulysses-Group.plist -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue+Bool.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSMValue: ExpressibleByBooleanLiteral where T == Bool { 4 | public init(booleanLiteral value: Bool) { 5 | self = .some(value) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue+Nil.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSMValue: ExpressibleByNilLiteral where T: ExpressibleByNilLiteral { 4 | public init(nilLiteral: ()) { 5 | self = .some(nil) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue+Interpolation.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSMValue: CustomStringConvertible { 4 | public var description: String { 5 | switch self { 6 | case let .some(value): "\(value)" 7 | default: "\(Self.self).any" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/Override.swift: -------------------------------------------------------------------------------- 1 | public extension Syntax { 2 | struct Override { 3 | public func callAsFunction( 4 | @MWTABuilder _ group: () -> [MatchingWhenThenActions] 5 | ) -> [MatchingWhenThenActions] { 6 | return group().asOverrides() 7 | } 8 | } 9 | } 10 | 11 | extension [Syntax.MatchingWhenThenActions] { 12 | func asOverrides() -> Self { 13 | (map(\.node) as? [OverridableNode])?.forEach { 14 | $0.isOverride = true 15 | } 16 | return self 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /TestPlans/SwiftFSMTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "6E0881C6-B000-4A98-BB5A-1DD6663DE788", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "testExecutionOrdering" : "random" 13 | }, 14 | "testTargets" : [ 15 | { 16 | "parallelizable" : true, 17 | "target" : { 18 | "containerPath" : "container:", 19 | "identifier" : "SwiftFSMTests", 20 | "name" : "SwiftFSMTests" 21 | } 22 | } 23 | ], 24 | "version" : 1 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/ResultBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ResultBuilder { 4 | associatedtype T 5 | } 6 | 7 | public extension ResultBuilder { 8 | static func buildExpression( _ row: [T]) -> [T] { 9 | row 10 | } 11 | 12 | static func buildExpression( _ row: T) -> [T] { 13 | [row] 14 | } 15 | 16 | static func buildBlock(_ cs: [T]...) -> [T] { 17 | cs.flattened 18 | } 19 | } 20 | 21 | extension Collection where Element: Collection { 22 | var flattened: [Element.Element] { 23 | flatMap { $0 } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Matching/Collection+Combinations.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection where Element: Collection { 4 | typealias Output = [[Element.Element]] 5 | 6 | func combinations() -> Output { 7 | guard !isEmpty else { return [] } 8 | 9 | return reduce([[]], combinations) 10 | } 11 | 12 | private func combinations(_ c1: Output, _ c2: Element) -> Output { 13 | c1.reduce(into: []) { combinations, elem1 in 14 | c2.forEach { elem2 in 15 | combinations.append(elem1 + [elem2]) 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/AnyTraceable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct AnyTraceable: @unchecked Sendable { 4 | let base: AnyHashable 5 | let file: String 6 | let line: Int 7 | 8 | init(_ base: H?, file: String, line: Int) { 9 | self.base = base! 10 | // this arcane syntax ensures 'base' is never optional 11 | self.file = file 12 | self.line = line 13 | } 14 | } 15 | 16 | extension AnyTraceable: Hashable { 17 | static func == (lhs: Self, rhs: Self) -> Bool { 18 | lhs.base == rhs.base 19 | } 20 | 21 | func hash(into hasher: inout Hasher) { 22 | hasher.combine(base) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue+Equatable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSMValue { 4 | public static func == (lhs: Self, rhs: Self) -> Bool { 5 | guard lhs.isSome, rhs.isSome else { return true } 6 | 7 | return lhs.wrappedValue == rhs.wrappedValue 8 | } 9 | 10 | public static func == (lhs: Self, rhs: T) -> Bool { 11 | lhs.wrappedValue == rhs 12 | } 13 | 14 | public static func == (lhs: T, rhs: Self) -> Bool { 15 | lhs == rhs.wrappedValue 16 | } 17 | 18 | public static func != (lhs: Self, rhs: T) -> Bool { 19 | lhs.wrappedValue != rhs 20 | } 21 | 22 | public static func != (lhs: T, rhs: Self) -> Bool { 23 | lhs != rhs.wrappedValue 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "a217560c1de9268368c95c798a750c705f880ab338a334cc8a977b0f57487677", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-algorithms", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-algorithms", 8 | "state" : { 9 | "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", 10 | "version" : "1.2.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-numerics", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-numerics", 17 | "state" : { 18 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 19 | "version" : "1.0.2" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/ThenBlockTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class ThenBlockTests: BlockTestsBase { 5 | func testThenBlockWithMTA() async { 6 | let node = (then(1) { mwaBlock }).thenBlockNode; let line = #line 7 | assertThenNode(node, state: 1, sutFile: #file, sutLine: line) 8 | await assertMWAResult(node.rest, sutLine: mwaLine) 9 | } 10 | 11 | func testThenBlockWithMA() async { 12 | let node = (then(1) { maBlock }).thenBlockNode; let line = #line 13 | assertThenNode(node, state: 1, sutFile: #file, sutLine: line) 14 | await assertMAResult(node.rest, sutLine: maLine) 15 | } 16 | } 17 | 18 | private extension Syntax.CompoundSyntaxGroup { 19 | var thenBlockNode: ThenBlockNode { 20 | node as! ThenBlockNode 21 | } 22 | } 23 | 24 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/FSM/AnyAction.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias FSMAction = @isolated(any) () async -> Void 4 | public typealias FSMActionWithEvent = @isolated(any) (Event) async -> Void 5 | 6 | public struct AnyAction: @unchecked Sendable { 7 | public enum NullEvent: FSMHashable { case null } 8 | 9 | private let base: Any 10 | 11 | init(_ action: @escaping FSMAction) { 12 | base = action 13 | } 14 | 15 | init(_ action: @escaping FSMActionWithEvent) { 16 | base = action 17 | } 18 | 19 | func callAsFunction(_ event: Event = NullEvent.null) async { 20 | if let base = self.base as? FSMAction { 21 | await base() 22 | } else if let base = self.base as? FSMActionWithEvent { 23 | await base(event) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/SyntaxNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol SyntaxNode { 4 | associatedtype Input: Sendable 5 | associatedtype Output: Sendable 6 | 7 | typealias Result = (output: [Output], errors: [Error]) 8 | 9 | var rest: [any SyntaxNode] { get set } 10 | 11 | func combinedWith(_ rest: [Input]) -> [Output] 12 | func findErrors() -> [Error] 13 | } 14 | 15 | extension SyntaxNode { 16 | func resolve() -> Result { 17 | var allOutput = [Input]() 18 | var allErrors = [Error]() 19 | 20 | rest.forEach { 21 | let resolved = $0.resolve() 22 | allOutput.append(contentsOf: resolved.output) 23 | allErrors.append(contentsOf: resolved.errors) 24 | } 25 | 26 | return (combinedWith(allOutput), findErrors() + allErrors) 27 | } 28 | 29 | func findErrors() -> [Error] { [] } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue+String.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSMValue: ExpressibleByUnicodeScalarLiteral where T == String { 4 | public init(unicodeScalarLiteral value: String) { 5 | self = .some(value) 6 | } 7 | } 8 | 9 | extension FSMValue: ExpressibleByExtendedGraphemeClusterLiteral where T == String { 10 | public init(extendedGraphemeClusterLiteral value: String) { 11 | self = .some(value) 12 | } 13 | } 14 | 15 | extension FSMValue: ExpressibleByStringLiteral where T == String { 16 | public init(stringLiteral value: String) { 17 | self = .some(value) 18 | } 19 | } 20 | 21 | extension FSMValue where T == String { 22 | static func + (lhs: Self, rhs: String) -> String { 23 | lhs.unsafeWrappedValue() + rhs 24 | } 25 | 26 | static func + (lhs: String, rhs: Self) -> String { 27 | lhs + rhs.unsafeWrappedValue() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Matching/CombinationsTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class CombinationsTests: XCTestCase { 5 | func assertCombinations(of a: [[Int]], expected: [[Int]], line: UInt = #line) { 6 | XCTAssertEqual(expected, a.combinations(), line: line) 7 | } 8 | 9 | func testCombinations() { 10 | assertCombinations(of: [], expected: []) 11 | assertCombinations(of: [[]], expected: []) 12 | assertCombinations(of: [[], []], expected: []) 13 | assertCombinations(of: [[1]], expected: [[1]]) 14 | assertCombinations(of: [[1, 2]], expected: [[1], [2]]) 15 | assertCombinations(of: [[1], [2]], expected: [[1, 2]]) 16 | assertCombinations(of: [[1], [2], [3]], expected: [[1, 2, 3]]) 17 | assertCombinations(of: [[1, 2], [3]], expected: [[1, 3], [2, 3]]) 18 | assertCombinations(of: [[1, 2], [3, 4]], expected: [[1, 3], [1, 4], [2, 3], [2, 4]]) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMVaue+Dictionary.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSMValue: ExpressibleByDictionaryLiteral where T: FSMDictionary { 4 | public init(dictionaryLiteral elements: (T.Key, T.Value)...) { 5 | self = .some(Dictionary(uniqueKeysWithValues: Array(elements)) as! T) 6 | } 7 | 8 | public subscript(key: Key) -> Value? { 9 | unsafeWrappedValue()[key] 10 | } 11 | 12 | public subscript( 13 | key: Key, 14 | default defaultValue: @autoclosure () -> Value 15 | ) -> Value { 16 | unsafeWrappedValue()[key, default: defaultValue()] 17 | } 18 | } 19 | 20 | public protocol FSMDictionary: Collection { 21 | associatedtype Key: Hashable 22 | associatedtype Value 23 | 24 | subscript(key: Key) -> Value? { get set } 25 | subscript( 26 | key: Key, 27 | default defaultValue: @autoclosure () -> Value 28 | ) -> Value { get set } 29 | } 30 | 31 | extension Dictionary: FSMDictionary { } 32 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/OverrideBlockTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class OverrideBlockTests: BlockTestsBase { 5 | func testOverride() { 6 | let o = overriding { mwtaBlock } 7 | XCTAssert((o.nodes.first as! OverridableNode).isOverride) 8 | } 9 | 10 | func testNestedOverride() { 11 | let d = define(1) { 12 | overriding { 13 | mwtaBlock 14 | } 15 | mwtaBlock 16 | } 17 | 18 | let g = d.node.rest.first as! GivenNode 19 | 20 | XCTAssertEqual(4, g.rest.count) 21 | 22 | let overridden = g.rest.prefix(2).map { $0 as! OverridableNode } 23 | let notOverridden = g.rest.suffix(2).map { $0 as! OverridableNode } 24 | 25 | overridden.forEach { 26 | XCTAssertTrue($0.isOverride) 27 | } 28 | 29 | notOverridden.forEach { 30 | XCTAssertFalse($0.isOverride) 31 | } 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/SuperState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct SuperState { 4 | internal var nodes: [any SyntaxNode] 5 | internal var onEntry: [AnyAction] 6 | internal var onExit: [AnyAction] 7 | 8 | internal init( 9 | nodes: [any SyntaxNode] = [], 10 | superStates: [SuperState], 11 | onEntry: [AnyAction], 12 | onExit: [AnyAction] 13 | ) { 14 | self.nodes = superStates.map(\.nodes).flattened + nodes 15 | self.onEntry = superStates.map(\.onEntry).flattened + onEntry 16 | self.onExit = superStates.map(\.onExit).flattened + onExit 17 | } 18 | } 19 | 20 | extension [any SyntaxNode] { 21 | func withOverrideGroupID() -> Self { 22 | let overrideGroupID = UUID() 23 | overridableNodes?.forEach { $0.overrideGroupID = overrideGroupID } 24 | return self 25 | } 26 | 27 | private var overridableNodes: [OverridableNode]? { 28 | self as? [OverridableNode] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel Segall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/Validation/MatchResolvingNode/MRNBase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol MatchResolvingNode: SyntaxNode { 4 | var errors: [Error] { get } 5 | init(rest: [any SyntaxNode]) 6 | func resolve() -> (output: [Transition], errors: [Error]) 7 | } 8 | 9 | extension MatchResolvingNode { 10 | func findErrors() -> [Error] { 11 | errors 12 | } 13 | } 14 | 15 | struct Transition: @unchecked Sendable { 16 | let condition: ConditionProvider?, 17 | state: AnyHashable, 18 | predicates: PredicateSet, 19 | event: AnyHashable, 20 | nextState: AnyHashable, 21 | actions: [AnyAction] 22 | 23 | init( 24 | _ condition: ConditionProvider?, 25 | _ state: AnyHashable, 26 | _ predicates: PredicateSet, 27 | _ event: AnyHashable, 28 | _ nextState: AnyHashable, 29 | _ actions: [AnyAction] 30 | ) { 31 | self.condition = condition 32 | self.state = state 33 | self.predicates = predicates 34 | self.event = event 35 | self.nextState = nextState 36 | self.actions = actions 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | import CompilerPluginSupport 6 | 7 | let package = Package( 8 | name: "swift-fsm", 9 | platforms: [ 10 | .macOS(.v15), 11 | .iOS(.v18), 12 | .tvOS(.v18), 13 | .watchOS(.v11) 14 | ], 15 | products: [ 16 | .library( 17 | name: "SwiftFSM", 18 | targets: ["SwiftFSM"] 19 | ), 20 | ], 21 | dependencies: [ 22 | .package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "SwiftFSM", 27 | dependencies: [ 28 | .product(name: "Algorithms", package: "swift-algorithms"), 29 | ], 30 | swiftSettings: [ 31 | .enableExperimentalFeature("StrictConcurrency=complete") 32 | ] 33 | ), 34 | 35 | .testTarget( 36 | name: "SwiftFSMTests", 37 | dependencies: [ 38 | "SwiftFSM", 39 | ] 40 | ), 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/Actions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Syntax { 4 | struct Actions { 5 | let actions: [AnyAction] 6 | let file: String 7 | let line: Int 8 | 9 | init( 10 | _ actions: [AnyAction], 11 | file: String = #file, 12 | line: Int = #line 13 | ) { 14 | self.actions = actions 15 | self.file = file 16 | self.line = line 17 | } 18 | 19 | public func callAsFunction( 20 | @MWTABuilder _ group: @isolated(any) () -> [MatchingWhenThenActions] 21 | ) -> MWTA_Group { 22 | .init(actions, file: file, line: line, group) 23 | } 24 | 25 | public func callAsFunction( 26 | @MWABuilder _ group: @isolated(any) () -> [MatchingWhenActions] 27 | ) -> MWA_Group { 28 | .init(actions, file: file, line: line, group) 29 | } 30 | 31 | public func callAsFunction( 32 | @MTABuilder _ group: @isolated(any) () -> [MatchingThenActions] 33 | ) -> MTA_Group { 34 | .init(actions, file: file, line: line, group) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/AnyTraceableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class AnyTraceableTests: SyntaxNodeTests { 5 | func testTraceableEquality() { 6 | let t1 = randomisedTrace("cat") 7 | let t2 = randomisedTrace("cat") 8 | let t3 = randomisedTrace("bat") 9 | let t4: AnyTraceable = "cat" 10 | 11 | XCTAssertEqual(t1, t2) 12 | XCTAssertEqual(t1, t4) 13 | XCTAssertNotEqual(t1, t3) 14 | } 15 | 16 | func testTraceableHashing() async { 17 | var randomCat: AnyTraceable { randomisedTrace("cat") } 18 | 19 | for _ in 0...1000 { 20 | let dict = [randomCat: randomCat] 21 | XCTAssertEqual(dict[randomCat], randomCat) 22 | } 23 | } 24 | 25 | func testTraceableDescription() { 26 | XCTAssertEqual(s1.description, "S1") 27 | } 28 | 29 | func testBangsOptionals() { 30 | let c1: String? = "cat" 31 | 32 | let t = AnyTraceable(c1, file: "", line: 0) 33 | let c2 = t.base 34 | 35 | XCTAssertTrue(String(describing: c1).contains("Optional")) 36 | XCTAssertFalse(String(describing: c2).contains("Optional")) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/AnyActionTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class AnyActionTestsBase: XCTestCase { 5 | var output = "" 6 | 7 | func pass() { 8 | output += "pass" 9 | } 10 | 11 | func passWithEvent(_ e: String) { 12 | output += e 13 | } 14 | 15 | func passAsync() async { 16 | pass() 17 | } 18 | 19 | func passWithEventAsync(_ e: String) async { 20 | passWithEvent(e) 21 | } 22 | } 23 | 24 | final class AnyActionTests: AnyActionTestsBase { 25 | func testCanCallAsyncActionWithNoArgs() async { 26 | let action = AnyAction(passAsync) 27 | await action() 28 | 29 | XCTAssertEqual(output, "pass") 30 | } 31 | 32 | func testAsyncActionWithNoArgsIgnoresEvent() async { 33 | let action = AnyAction(passAsync) 34 | await action("fail") 35 | 36 | XCTAssertEqual(output, "pass") 37 | } 38 | 39 | func testCanCallSyncActionWithNoArgsWithAsync() async { 40 | let action = AnyAction(pass) 41 | await action() 42 | 43 | XCTAssertEqual(output, "pass") 44 | } 45 | 46 | func testCanCallAsyncActionWithEventArg() async { 47 | let action = AnyAction(passWithEventAsync) 48 | await action("pass") 49 | 50 | XCTAssertEqual(output, "pass") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/ActionsNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class ActionsNodeTests: SyntaxNodeTests { 5 | func testEmptyActions() { 6 | let finalised = ActionsNode(actions: [], rest: []).resolve() 7 | let output = finalised.output 8 | let errors = finalised.errors 9 | 10 | XCTAssertTrue(errors.isEmpty) 11 | guard assertCount(output, expected: 1) else { return } 12 | assertEqual(RawSyntaxDTO(MatchDescriptorChain(), nil, nil, actions), output.first) 13 | } 14 | 15 | func testEmptyActionsBlockIsError() { 16 | assertEmptyNodeWithError(ActionsBlockNode(actions: [], rest: [])) 17 | } 18 | 19 | func testEmptyActionsBlockHasNoOutput() { 20 | assertCount(ActionsBlockNode(actions: [], rest: []).resolve().output, expected: 0) 21 | } 22 | 23 | func testActionsFinalisesCorrectly() async { 24 | let n = actionsNode 25 | await n.resolve().output.executeAll() 26 | XCTAssertEqual("12", actionsOutput) 27 | XCTAssertTrue(n.resolve().errors.isEmpty) 28 | } 29 | 30 | func testActionsPlusChainFinalisesCorrectly() async { 31 | let a = ActionsNode(actions: [AnyAction({ self.actionsOutput += "action" })]) 32 | await assertDefaultIONodeChains(node: a, expectedOutput: "actionchain") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "master", "backport", "develop" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-15 16 | 17 | steps: 18 | - uses: maxim-lobanov/setup-xcode@v1 19 | with: 20 | xcode-version: latest 21 | - uses: actions/checkout@v4 22 | - name: Build 23 | run: swift build -v 24 | - name: Run tests 25 | run: swift test -v --parallel --enable-code-coverage --xunit-output results.xml 26 | - name: Gather code coverage 27 | run: xcrun llvm-cov export -format="lcov" .build/debug/Swift-fsmPackageTests.xctest/Contents/MacOS/Swift-fsmPackageTests -instr-profile .build/debug/codecov/default.profdata > coverage_report.lcov 28 | - name: Upload coverage to Codecov 29 | uses: codecov/codecov-action@v4 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | fail_ci_if_error: true 33 | files: ./coverage_report.lcov 34 | verbose: true 35 | 36 | - uses: testspace-com/setup-testspace@v1 37 | with: 38 | domain: ${{github.repository_owner}} 39 | 40 | - name: Publish Results to Testspace 41 | run: testspace results.xml 42 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/MatchingNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class MatchingNodeTests: SyntaxNodeTests { 5 | func testEmptyMatchNodeIsNotError() { 6 | assertCount(MatchingNode(descriptor: MatchDescriptorChain(), rest: []).resolve().errors, expected: 0) 7 | } 8 | 9 | func testEmptyMatchBlockNodeIsError() { 10 | assertEmptyNodeWithError(MatchingBlockNode(descriptor: MatchDescriptorChain(), rest: [])) 11 | } 12 | 13 | func testEmptyMatchBlockNodeHasNoOutput() { 14 | assertCount(MatchingBlockNode(descriptor: MatchDescriptorChain(), rest: []).resolve().output, expected: 0) 15 | } 16 | 17 | func testMatchNodeFinalisesCorrectly() async { 18 | await assertMatch(MatchingNode(descriptor: MatchDescriptorChain(), rest: [whenNode])) 19 | } 20 | 21 | func testMatchNodeWithChainFinalisesCorrectly() async { 22 | let m = MatchingNode(descriptor: MatchDescriptorChain(any: S.b, all: R.a)) 23 | await assertDefaultIONodeChains(node: m, expectedMatch: MatchDescriptorChain(any: [[P.a], [S.b]], 24 | all: Q.a, R.a)) 25 | } 26 | 27 | func testMatchNodeCanSetRestAfterInit() async { 28 | let m = MatchingNode(descriptor: MatchDescriptorChain()) 29 | m.rest.append(whenNode) 30 | await assertMatch(m) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/FSM/EagerFSM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSM { 4 | class Eager: Base { 5 | override func makeMatchResolvingNode( 6 | rest: [any SyntaxNode] 7 | ) -> any MatchResolvingNode { 8 | EagerMatchResolvingNode(rest: rest) 9 | } 10 | 11 | @discardableResult 12 | override func handleEvent( 13 | _ event: Event, 14 | predicates: [any Predicate], 15 | isolation: isolated (any Actor)? = #isolation 16 | ) async -> TransitionStatus { 17 | let status = await super.handleEvent( 18 | event, 19 | predicates: predicates, 20 | isolation: isolation 21 | ) 22 | 23 | logTransitionStatus(status, for: event, with: predicates) 24 | return status 25 | } 26 | 27 | private func logTransitionStatus( 28 | _ status: TransitionStatus, 29 | for event: Event, 30 | with predicates: [any Predicate] 31 | ) { 32 | switch status { 33 | case let .executed(transition): 34 | logTransitionExecuted(transition) 35 | case let .notExecuted(transition): 36 | logTransitionNotExecuted(transition) 37 | case .notFound: 38 | logTransitionNotFound(event, predicates) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue+Array.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSMValue: ExpressibleByArrayLiteral where T: FSMArray { 4 | public init(arrayLiteral elements: T.Element...) { 5 | self = .some(elements as! T) 6 | } 7 | } 8 | 9 | extension FSMValue: Sequence where T: RandomAccessCollection { 10 | public func makeIterator() -> T.Iterator { 11 | unsafeWrappedValue().makeIterator() 12 | } 13 | 14 | public typealias Iterator = T.Iterator 15 | public typealias Element = T.Element 16 | } 17 | 18 | extension FSMValue: Collection where T: RandomAccessCollection { 19 | public func index(after i: T.Index) -> T.Index { 20 | unsafeWrappedValue().index(after: i) 21 | } 22 | 23 | public subscript(position: T.Index) -> T.Element { 24 | unsafeWrappedValue()[position] 25 | } 26 | 27 | public var startIndex: T.Index { 28 | unsafeWrappedValue().startIndex 29 | } 30 | 31 | public var endIndex: T.Index { 32 | unsafeWrappedValue().endIndex 33 | } 34 | 35 | public typealias Index = T.Index 36 | } 37 | 38 | extension FSMValue: BidirectionalCollection where T: RandomAccessCollection { 39 | public func index(before i: T.Index) -> T.Index { 40 | unsafeWrappedValue().index(before: i) 41 | } 42 | } 43 | 44 | extension FSMValue: RandomAccessCollection where T: RandomAccessCollection { } 45 | 46 | public protocol FSMArray: RandomAccessCollection { } 47 | extension Array: FSMArray { } 48 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue+Comparable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension FSMValue where T: Comparable { 4 | public static func > (lhs: Self, rhs: T) -> Bool { 5 | guard let value = lhs.wrappedValue else { return false } 6 | 7 | return value > rhs 8 | } 9 | 10 | public static func < (lhs: Self, rhs: T) -> Bool { 11 | guard let value = lhs.wrappedValue else { return false } 12 | 13 | return value < rhs 14 | } 15 | 16 | public static func >= (lhs: Self, rhs: T) -> Bool { 17 | guard let value = lhs.wrappedValue else { return false } 18 | 19 | return value >= rhs 20 | } 21 | 22 | public static func <= (lhs: Self, rhs: T) -> Bool { 23 | guard let value = lhs.wrappedValue else { return false } 24 | 25 | return value <= rhs 26 | } 27 | 28 | public static func > (lhs: T, rhs: Self) -> Bool { 29 | guard let value = rhs.wrappedValue else { return false } 30 | 31 | return lhs > value 32 | } 33 | 34 | public static func < (lhs: T, rhs: Self) -> Bool { 35 | guard let value = rhs.wrappedValue else { return false } 36 | 37 | return lhs < value 38 | } 39 | 40 | public static func >= (lhs: T, rhs: Self) -> Bool { 41 | guard let value = rhs.wrappedValue else { return false } 42 | 43 | return lhs >= value 44 | } 45 | 46 | public static func <= (lhs: T, rhs: Self) -> Bool { 47 | guard let value = rhs.wrappedValue else { return false } 48 | 49 | return lhs <= value 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/GivenNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct GivenNode: SyntaxNode { 4 | struct Output { 5 | let state: AnyTraceable, 6 | descriptor: MatchDescriptorChain, 7 | event: AnyTraceable, 8 | nextState: AnyTraceable, 9 | actions: [AnyAction], 10 | overrideGroupID: UUID, 11 | isOverride: Bool 12 | 13 | init( 14 | _ state: AnyTraceable, 15 | _ match: MatchDescriptorChain, 16 | _ event: AnyTraceable, 17 | _ nextState: AnyTraceable, 18 | _ actions: [AnyAction], 19 | _ overrideGroupID: UUID, 20 | _ isOverride: Bool 21 | ) { 22 | self.state = state 23 | self.descriptor = match 24 | self.event = event 25 | self.nextState = nextState 26 | self.actions = actions 27 | self.overrideGroupID = overrideGroupID 28 | self.isOverride = isOverride 29 | } 30 | } 31 | 32 | let states: [AnyTraceable] 33 | var rest: [any SyntaxNode] = [] 34 | 35 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [Output] { 36 | states.reduce(into: []) { result, state in 37 | rest.forEach { 38 | result.append(Output(state, 39 | $0.descriptor, 40 | $0.event!, 41 | $0.state ?? state, 42 | $0.actions, 43 | $0.overrideGroupID, 44 | $0.isOverride)) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/ThenNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class ThenNodeTests: SyntaxNodeTests { 5 | func testNilThenNodeState() { 6 | assertEmptyThen(ThenNode(state: nil, rest: []), thenState: nil) 7 | } 8 | 9 | func testEmptyThenNode() { 10 | assertEmptyThen(ThenNode(state: s1, rest: [])) 11 | } 12 | 13 | func testThenNodeWithEmptyRest() { 14 | assertEmptyThen(ThenNode(state: s1, rest: [ActionsNode(actions: [])])) 15 | } 16 | 17 | func testEmptyThenBlockNodeIsError() { 18 | assertEmptyNodeWithError(ThenBlockNode(state: s1, rest: [])) 19 | } 20 | 21 | func testEmptyThenBlockNodeHasNoOutput() { 22 | assertCount(ThenBlockNode(state: s1, rest: []).resolve().output, expected: 0) 23 | } 24 | 25 | func testThenNodeFinalisesCorrectly() async { 26 | await assertThenWithActions(expected: "12", ThenNode(state: s1, rest: [actionsNode])) 27 | } 28 | 29 | func testThenNodePlusChainFinalisesCorrectly() async { 30 | let t = ThenNode(state: s2) 31 | await assertDefaultIONodeChains(node: t, expectedState: s2) 32 | } 33 | 34 | func testThenNodeCanSetRestAfterInit() async { 35 | let t = ThenNode(state: s1) 36 | t.rest.append(actionsNode) 37 | await assertThenWithActions(expected: "12", t) 38 | } 39 | 40 | func testThenNodeFinalisesWithMultipleActionsNodes() async { 41 | await assertThenWithActions(expected: "1212", 42 | ThenNode(state: s1, rest: [actionsNode, 43 | actionsNode]) 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/WhenBlockTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class WhenBlockTests: BlockTestsBase { 5 | func assert( 6 | _ b: Syntax.MWTA_Group, 7 | events: [Int] = [1, 2], 8 | nodeLine nl: Int = #line, 9 | restLine rl: Int, 10 | xctLine xl: UInt = #line 11 | ) async { 12 | let node = b.node as! WhenBlockNode 13 | assertWhenNode(node, events: events, sutLine: nl, xctLine: xl) 14 | await assertMTAResult(node.rest, sutLine: rl, xctLine: xl) 15 | } 16 | 17 | func assert( 18 | _ b: Syntax.MWA_Group, 19 | expectedOutput eo: String = BlockTestsBase.defaultOutput, 20 | events: [Int] = [1, 2], 21 | nodeLine nl: Int = #line, 22 | restLine rl: Int, 23 | xctLine xl: UInt = #line 24 | ) async { 25 | let wbn = b.node as! WhenBlockNode 26 | assertWhenNode(wbn, events: events, sutLine: nl, xctLine: xl) 27 | 28 | let actionsNode = wbn.rest.first as! ActionsNode 29 | await assertActions(actionsNode.actions, expectedOutput: eo, xctLine: xl) 30 | 31 | let matchNode = actionsNode.rest.first as! MatchingNode 32 | await assertMatchNode(matchNode, all: [P.a], sutFile: baseFile, sutLine: rl, xctLine: xl) 33 | } 34 | 35 | func testWhenBlockWithMTA() async { 36 | await assert(when(1, or: 2) { mtaBlock }, restLine: mtaLine) 37 | await assert(when(1) { mtaBlock }, events: [1], restLine: mtaLine) 38 | } 39 | 40 | func testWhenBlockWithMA() async { 41 | await assert(when(1, or: 2) { maBlock }, restLine: maLine) 42 | await assert(when(1) { maBlock }, events: [1], restLine: maLine) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum FSMValue: FSMHashable { 4 | case some(T), any 5 | 6 | public var wrappedValue: T? { 7 | try? throwingWrappedValue() 8 | } 9 | 10 | func unsafeWrappedValue(_ f: String = #function) -> T { 11 | try! throwingWrappedValue(f) 12 | } 13 | 14 | func throwingWrappedValue(_ f: String = #function) throws -> T { 15 | switch self { 16 | case let .some(value): value 17 | default: try thrower.throw(instance: "\(self)", function: f) 18 | } 19 | } 20 | 21 | var isSome: Bool { 22 | if case .some = self { 23 | true 24 | } else { 25 | false 26 | } 27 | } 28 | } 29 | 30 | public protocol EventWithValues: FSMHashable { } 31 | extension EventWithValues { 32 | public func hash(into hasher: inout Hasher) { 33 | hasher.combine(caseName) 34 | } 35 | 36 | var caseName: some StringProtocol { 37 | String(describing: self).lazy.split(separator: "(").first! 38 | } 39 | } 40 | 41 | // MARK: - Internal 42 | 43 | protocol Throwing { 44 | func `throw`(instance: String, function: String) throws -> Never 45 | } 46 | 47 | private struct Thrower: Throwing { 48 | func `throw`(instance i: String, function f: String) throws -> Never { 49 | throw "\(i) has no value - the operation \(f) is invalid." 50 | } 51 | } 52 | 53 | nonisolated(unsafe) private var thrower: any Throwing = Thrower() 54 | 55 | #if DEBUG 56 | extension FSMValue { 57 | static func setThrower(_ t: some Throwing) { 58 | thrower = t 59 | } 60 | 61 | static func resetThrower() { 62 | thrower = Thrower() 63 | } 64 | } 65 | #endif 66 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/FSM/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class Logger { 4 | func transitionNotFound(_ event: Event, _ predicates: [any Predicate]) { 5 | #if DEBUG 6 | warning(transitionNotFoundString(event, predicates)) 7 | #endif 8 | } 9 | 10 | func transitionNotFoundString( 11 | _ event: Event, 12 | _ predicates: [any Predicate] 13 | ) -> String { 14 | let warning = "no transition found for event '\(event)'" 15 | 16 | return predicates.isEmpty 17 | ? warning 18 | : warning + " matching predicates \(predicates)" 19 | } 20 | 21 | func transitionNotExecuted(_ t: Transition) { 22 | #if DEBUG 23 | info(transitionNotExecutedString(t)) 24 | #endif 25 | } 26 | 27 | func transitionNotExecutedString(_ t: Transition) -> String { 28 | "conditional transition \(t.toString) not executed" 29 | } 30 | 31 | func transitionExecuted(_ t: Transition) { 32 | #if DEBUG 33 | info(transitionExecutedString(t)) 34 | #endif 35 | } 36 | 37 | func transitionExecutedString(_ t: Transition) -> String { 38 | "transition \(t.toString) was executed" 39 | } 40 | 41 | private func warning(_ s: String) { 42 | print(intro + "Warning: " + s) 43 | } 44 | 45 | private func info(_ s: String) { 46 | print(intro + "Info: " + s) 47 | } 48 | 49 | private var intro: String { 50 | "[\(Date().formatted(date: .omitted, time: .standard)) SwiftFSM] " 51 | } 52 | } 53 | 54 | private extension Transition { 55 | var toString: String { 56 | predicates.isEmpty 57 | ? "{ define(\(state)) | when(\(event)) | then(\(nextState)) }" 58 | : "{ define(\(state)) | matching(\(predicates)) | when(\(event)) | then(\(nextState)) }" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/WhenNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class WhenNodeTests: SyntaxNodeTests { 5 | func testEmptyWhenNode() { 6 | assertEmptyNodeWithError(WhenNode(events: [], rest: [])) 7 | } 8 | 9 | func testEmptyWhenNodeWithActions() { 10 | assertEmptyNodeWithError(WhenNode(events: [], rest: [thenNode])) 11 | } 12 | 13 | func testEmptyWhenBlockNodeWithActions() { 14 | assertEmptyNodeWithError(WhenBlockNode(events: [e1])) 15 | } 16 | 17 | func testEmptyWhenBlockNodeHasNoOutput() { 18 | assertCount(WhenBlockNode(events: [e1]).resolve().output, expected: 0) 19 | } 20 | 21 | func testWhenNodeWithEmptyRest() async { 22 | await assertWhen( 23 | state: nil, 24 | actionsCount: 0, 25 | actionsOutput: "", 26 | node: WhenNode(events: [e1, e2], rest: []), 27 | line: #line 28 | ) 29 | } 30 | 31 | func assertWhenNodeWithActions( 32 | expected: String = "1212", 33 | _ w: WhenNode, 34 | line: UInt = #line 35 | ) async { 36 | await assertWhen( 37 | state: s1, 38 | actionsCount: 2, 39 | actionsOutput: expected, 40 | node: w, 41 | line: line 42 | ) 43 | } 44 | 45 | func testWhenNodeFinalisesCorrectly() async { 46 | await assertWhenNodeWithActions(WhenNode(events: [e1, e2], rest: [thenNode])) 47 | } 48 | 49 | func testWhenNodeWithChainFinalisesCorrectly() async { 50 | let w = WhenNode(events: [e3]) 51 | await assertDefaultIONodeChains(node: w, expectedEvent: e3) 52 | } 53 | 54 | func testWhenNodeCanSetRestAfterInit() async { 55 | let w = WhenNode(events: [e1, e2]) 56 | w.rest.append(thenNode) 57 | await assertWhenNodeWithActions(w) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/ThenNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class ThenNodeBase: OverridableNode { 4 | let state: AnyTraceable? 5 | var rest: [any SyntaxNode] 6 | 7 | init( 8 | state: AnyTraceable?, 9 | rest: [any SyntaxNode] = [], 10 | overrideGroupID: UUID = UUID(), 11 | isOverride: Bool = false 12 | ) { 13 | self.state = state 14 | self.rest = rest 15 | super.init(overrideGroupID: overrideGroupID, isOverride: isOverride) 16 | } 17 | 18 | func makeOutput(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 19 | rest.reduce(into: []) { 20 | $0.append(RawSyntaxDTO($1.descriptor, 21 | $1.event, 22 | state, 23 | $1.actions, 24 | overrideGroupID, 25 | isOverride)) 26 | } 27 | } 28 | } 29 | 30 | class ThenNode: ThenNodeBase, SyntaxNode { 31 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 32 | makeOutput(rest) ??? makeDefaultIO(state: state) 33 | } 34 | } 35 | 36 | class ThenBlockNode: ThenNodeBase, NeverEmptyNode { 37 | let caller: String 38 | let file: String 39 | let line: Int 40 | 41 | init( 42 | state: AnyTraceable?, 43 | rest: [any SyntaxNode], 44 | caller: String = #function, 45 | overrideGroupID: UUID = UUID(), 46 | isOverride: Bool = false, 47 | file: String = #file, 48 | line: Int = #line 49 | ) { 50 | self.caller = caller 51 | self.file = file 52 | self.line = line 53 | 54 | super.init(state: state, 55 | rest: rest, 56 | overrideGroupID: overrideGroupID, 57 | isOverride: isOverride) 58 | } 59 | 60 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 61 | makeOutput(rest) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/Then.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Syntax { 4 | struct Then { 5 | public static func | ( 6 | lhs: Self, 7 | rhs: @escaping FSMAction 8 | ) -> MatchingThenActions { 9 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 10 | } 11 | 12 | public static func | ( 13 | lhs: Self, 14 | rhs: @escaping FSMActionWithEvent 15 | ) -> MatchingThenActions { 16 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 17 | } 18 | 19 | public static func | ( 20 | lhs: Self, 21 | rhs: [AnyAction] 22 | ) -> MatchingThenActions { 23 | .init(node: ActionsNode(actions: rhs, rest: [lhs.node])) 24 | } 25 | 26 | let node: ThenNode 27 | let file: String 28 | let line: Int 29 | 30 | var blockNode: ThenBlockNode { 31 | ThenBlockNode(state: node.state, 32 | rest: node.rest, 33 | caller: "then", 34 | file: file, 35 | line: line) 36 | } 37 | 38 | init(_ state: State? = nil, file: String = #file, line: Int = #line) { 39 | let state = state != nil ? AnyTraceable(state, 40 | file: file, 41 | line: line) : nil 42 | node = ThenNode(state: state) 43 | self.file = file 44 | self.line = line 45 | } 46 | 47 | public func callAsFunction( 48 | @MWABuilder _ group: () -> [MatchingWhenActions] 49 | ) -> MWTA_Group { 50 | .init(blockNode, group) 51 | } 52 | 53 | public func callAsFunction( 54 | @MABuilder _ group: () -> [MatchingActions] 55 | ) -> MTA_Group { 56 | .init(blockNode, group) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/WhenNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class WhenNodeBase: OverridableNode { 4 | let events: [AnyTraceable] 5 | var rest: [any SyntaxNode] 6 | 7 | let caller: String 8 | let file: String 9 | let line: Int 10 | 11 | init( 12 | events: [AnyTraceable], 13 | rest: [any SyntaxNode] = [], 14 | overrideGroupID: UUID = UUID(), 15 | isOverride: Bool = false, 16 | caller: String = #function, 17 | file: String = #file, 18 | line: Int = #line 19 | ) { 20 | self.events = events 21 | self.rest = rest 22 | self.caller = caller 23 | self.file = file 24 | self.line = line 25 | 26 | super.init(overrideGroupID: overrideGroupID, isOverride: isOverride) 27 | } 28 | 29 | func makeOutput(_ rest: [RawSyntaxDTO], _ event: AnyTraceable) -> [RawSyntaxDTO] { 30 | rest.reduce(into: []) { 31 | $0.append(RawSyntaxDTO($1.descriptor, 32 | event, 33 | $1.state, 34 | $1.actions, 35 | overrideGroupID, 36 | isOverride)) 37 | } 38 | } 39 | } 40 | 41 | class WhenNode: WhenNodeBase, NeverEmptyNode { 42 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 43 | events.reduce(into: []) { output, event in 44 | output.append(contentsOf: makeOutput(rest, event) ??? makeDefaultIO(event: event)) 45 | } 46 | } 47 | 48 | func findErrors() -> [Error] { 49 | makeError(if: events.isEmpty) 50 | } 51 | } 52 | 53 | class WhenBlockNode: WhenNodeBase, NeverEmptyNode { 54 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 55 | events.reduce(into: []) { output, event in 56 | output.append(contentsOf: makeOutput(rest, event)) 57 | } 58 | } 59 | 60 | func findErrors() -> [Error] { 61 | makeError(if: events.isEmpty || rest.isEmpty) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/ActionsNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class ActionsNodeBase: OverridableNode { 4 | let actions: [AnyAction] 5 | var rest: [any SyntaxNode] 6 | 7 | init( 8 | actions: [AnyAction] = [], 9 | rest: [any SyntaxNode] = [], 10 | overrideGroupID: UUID = UUID(), 11 | isOverride: Bool = false 12 | ) { 13 | self.actions = actions 14 | self.rest = rest 15 | super.init(overrideGroupID: overrideGroupID, isOverride: isOverride) 16 | } 17 | 18 | func makeOutput(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 19 | rest.reduce(into: []) { 20 | $0.append( 21 | RawSyntaxDTO( 22 | $1.descriptor, 23 | $1.event, 24 | $1.state, 25 | actions + $1.actions, 26 | overrideGroupID, 27 | isOverride 28 | ) 29 | ) 30 | } 31 | } 32 | } 33 | 34 | class ActionsNode: ActionsNodeBase, SyntaxNode { 35 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 36 | makeOutput(rest) ??? makeDefaultIO(actions: actions) 37 | } 38 | } 39 | 40 | class ActionsBlockNode: ActionsNodeBase, NeverEmptyNode { 41 | let caller: String 42 | let file: String 43 | let line: Int 44 | 45 | init( 46 | actions: [AnyAction], 47 | rest: [any SyntaxNode], 48 | overrideGroupID: UUID = UUID(), 49 | isOverride: Bool = false, 50 | caller: String = #function, 51 | file: String = #file, 52 | line: Int = #line 53 | ) { 54 | self.caller = caller 55 | self.file = file 56 | self.line = line 57 | 58 | super.init( 59 | actions: actions, 60 | rest: rest, 61 | overrideGroupID: overrideGroupID, 62 | isOverride: isOverride 63 | ) 64 | } 65 | 66 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 67 | makeOutput(rest) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/MatchingNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | class MatchingNodeBase: OverridableNode { 4 | let descriptor: MatchDescriptorChain 5 | var rest: [any SyntaxNode] 6 | 7 | init( 8 | descriptor: MatchDescriptorChain, 9 | rest: [any SyntaxNode] = [], 10 | overrideGroupID: UUID = UUID(), 11 | isOverride: Bool = false 12 | ) { 13 | self.descriptor = descriptor 14 | self.rest = rest 15 | super.init(overrideGroupID: overrideGroupID, isOverride: isOverride) 16 | } 17 | 18 | func makeOutput(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 19 | rest.reduce(into: []) { 20 | $0.append(RawSyntaxDTO($1.descriptor.prepend(descriptor), 21 | $1.event, 22 | $1.state, 23 | $1.actions, 24 | overrideGroupID, 25 | isOverride)) 26 | } 27 | } 28 | } 29 | 30 | class MatchingNode: MatchingNodeBase, SyntaxNode { 31 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 32 | makeOutput(rest) ??? makeDefaultIO(match: descriptor) 33 | } 34 | } 35 | 36 | class MatchingBlockNode: MatchingNodeBase, NeverEmptyNode { 37 | let caller: String 38 | let file: String 39 | let line: Int 40 | 41 | init( 42 | descriptor: MatchDescriptorChain, 43 | rest: [any SyntaxNode] = [], 44 | overrideGroupID: UUID = UUID(), 45 | isOverride: Bool = false, 46 | caller: String = #function, 47 | file: String = #file, 48 | line: Int = #line 49 | ) { 50 | self.caller = caller 51 | self.file = file 52 | self.line = line 53 | 54 | super.init(descriptor: descriptor, 55 | rest: rest, 56 | overrideGroupID: overrideGroupID, 57 | isOverride: isOverride) 58 | } 59 | 60 | func combinedWith(_ rest: [RawSyntaxDTO]) -> [RawSyntaxDTO] { 61 | makeOutput(rest) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Matching/AnyPredicate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | typealias PredicateSets = Set 4 | typealias PredicateSet = Set 5 | 6 | public protocol Predicate: CaseIterable, FSMHashable { } 7 | 8 | extension Predicate { 9 | func erased() -> AnyPredicate { 10 | AnyPredicate(base: self) 11 | } 12 | 13 | var allCases: [any Predicate] { 14 | Self.allCases as! [any Predicate] 15 | } 16 | } 17 | 18 | struct AnyPredicate: @unchecked Sendable, Hashable, CustomStringConvertible { 19 | let base: AnyHashable 20 | 21 | init(base: P) { 22 | self.base = AnyHashable(base) 23 | } 24 | 25 | func unwrap(to: P.Type) -> P { 26 | base as! P 27 | } 28 | 29 | var allCases: [Self] { 30 | (base as! any Predicate).allCases.erased() 31 | } 32 | 33 | var description: String { 34 | type + "." + String(describing: base) 35 | } 36 | 37 | var type: String { 38 | String(describing: Swift.type(of: base.base)) 39 | } 40 | } 41 | 42 | extension Array { 43 | func erased() -> [AnyPredicate] where Element: Predicate { _erased() } 44 | func erased() -> [AnyPredicate] where Element == any Predicate { _erased() } 45 | 46 | private func _erased() -> [AnyPredicate] { 47 | map { ($0 as! any Predicate).erased() } 48 | } 49 | } 50 | 51 | extension Collection where Element == AnyPredicate { 52 | var combinationsOfAllCases: PredicateSets { 53 | Set( 54 | uniqueTypes() 55 | .map(\.allCases) 56 | .combinations() 57 | .map(Set.init) 58 | ) 59 | } 60 | 61 | func uniqueTypes() -> [AnyPredicate] { 62 | uniqueElementTypes.reduce(into: []) { predicates, type in 63 | predicates.append(first { $0.type == type }!) 64 | } 65 | } 66 | 67 | var elementsAreUnique: Bool { 68 | Set(self).count == count 69 | } 70 | 71 | var areUniquelyTyped: Bool { 72 | uniqueElementTypes.count == count 73 | } 74 | 75 | var uniqueElementTypes: Set { 76 | Set(map(\.type)) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/ResultBuilderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class ResultBuilderTests: XCTestCase { 5 | @resultBuilder 6 | struct Builder: ResultBuilder { 7 | typealias T = String 8 | } 9 | 10 | func build(@Builder s: () -> [String]) -> String { 11 | s().joined() 12 | } 13 | 14 | func testEmptyBuilder() { 15 | let s = build { } 16 | 17 | XCTAssertEqual("", s) 18 | } 19 | 20 | func testBuilderWithOneEmptyArgument() { 21 | let s = build { "" } 22 | 23 | XCTAssertEqual("", s) 24 | } 25 | 26 | func testBuilderWithOneArgument() { 27 | let s = build { 28 | "Cat" 29 | } 30 | 31 | XCTAssertEqual("Cat", s) 32 | } 33 | 34 | func testBuilderWithMultipleEmptyArguments() { 35 | let s = build { 36 | "" 37 | "" 38 | } 39 | 40 | XCTAssertEqual("", s) 41 | } 42 | 43 | func testBuilderWithMultipleArguments() { 44 | let s = build { 45 | "The " 46 | "cat " 47 | "sat " 48 | "on " 49 | "the " 50 | "mat" 51 | } 52 | 53 | XCTAssertEqual("The cat sat on the mat", s) 54 | } 55 | 56 | func testBuilderWithEmptyArrayArgument() { 57 | let s = build { 58 | [] 59 | } 60 | 61 | XCTAssertEqual("", s) 62 | } 63 | 64 | func testBuilderWithArrayArgument() { 65 | let s = build { 66 | ["The ", "cat ", "sat ", "on ", "the ", "mat"] 67 | } 68 | 69 | XCTAssertEqual("The cat sat on the mat", s) 70 | } 71 | 72 | func testBuilderWithEmptyArrayArguments() { 73 | let s = build { 74 | [] 75 | [] 76 | } 77 | 78 | XCTAssertEqual("", s) 79 | } 80 | 81 | func testBuilderWithArrayArguments() { 82 | let s = build { 83 | ["The ", "cat ", "sat "] 84 | ["on ", "the ", "mat"] 85 | } 86 | 87 | XCTAssertEqual("The cat sat on the mat", s) 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/Validation/MatchResolvingNode/LazyMRN.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class LazyMatchResolvingNode: MatchResolvingNode { 4 | var rest: [any SyntaxNode] 5 | var errors: [Error] = [] 6 | 7 | required init(rest: [any SyntaxNode] = []) { 8 | self.rest = rest 9 | } 10 | 11 | func combinedWith(_ rest: [SemanticValidationNode.Output]) -> [Transition] { 12 | do { 13 | return try rest.reduce(into: []) { result, input in 14 | func appendTransition(predicates: PredicateSet) throws { 15 | let t = Transition(io: input, predicates: predicates) 16 | guard !result.containsClash(t) else { throw "" } 17 | result.append(t) 18 | } 19 | 20 | let allPredicates = input.descriptor.combineAnyAndAll() 21 | 22 | if allPredicates.isEmpty { 23 | try appendTransition(predicates: []) 24 | } else { 25 | try allPredicates.forEach(appendTransition) 26 | } 27 | } 28 | } catch { 29 | errors = EagerMatchResolvingNode(rest: self.rest).resolve().errors 30 | return [] 31 | } 32 | } 33 | } 34 | 35 | extension Transition { 36 | init(io: OverrideSyntaxDTO, predicates p: PredicateSet) { 37 | condition = io.descriptor.condition 38 | state = io.state.base 39 | predicates = p 40 | event = io.event.base 41 | nextState = io.nextState.base 42 | actions = io.actions 43 | } 44 | 45 | var predicateTypes: Set { 46 | Set(predicates.map(\.type)) 47 | } 48 | 49 | func clashes(with t: Transition) -> Bool { 50 | (state, event) == (t.state, t.event) 51 | } 52 | 53 | func predicateTypesOverlap(with t: Transition) -> Bool { 54 | predicateTypes.isDisjoint(with: t.predicateTypes) 55 | } 56 | } 57 | 58 | extension [Transition] { 59 | func containsClash(_ t: Transition) -> Bool { 60 | filter { 61 | t.clashes(with: $0) && 62 | t.predicates.count == $0.predicates.count 63 | } 64 | .contains { 65 | t.predicateTypesOverlap(with: $0) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/OperatorSyntax/AnyActionOperators.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension AnyAction { 4 | static func & (lhs: Self, rhs: @escaping FSMAction) -> [Self] { 5 | [lhs, .init(rhs)] 6 | } 7 | 8 | static func & ( 9 | lhs: Self, 10 | rhs: @escaping FSMActionWithEvent 11 | ) -> [Self] { 12 | [lhs, .init(rhs)] 13 | } 14 | } 15 | 16 | // MARK: - init with a single FSMAction element, avoiding AnyAction.init 17 | public extension Array { 18 | init(_ action: @escaping FSMAction) { 19 | self.init(arrayLiteral: AnyAction(action)) 20 | } 21 | 22 | init(_ action: @escaping FSMActionWithEvent) { 23 | self.init(arrayLiteral: AnyAction(action)) 24 | } 25 | 26 | // MARK: combining with single FSMAction elements 27 | static func & (lhs: Self, rhs: @escaping FSMAction) -> Self { 28 | lhs + [.init(rhs)] 29 | } 30 | 31 | static func & ( 32 | lhs: Self, 33 | rhs: @escaping FSMActionWithEvent 34 | ) -> Self { 35 | lhs + [.init(rhs)] 36 | } 37 | } 38 | 39 | // MARK: - convenience operators, avoiding AnyAction.init 40 | public func & ( 41 | lhs: @escaping FSMAction, 42 | rhs: @escaping FSMAction 43 | ) -> [AnyAction] { 44 | [.init(lhs), .init(rhs)] 45 | } 46 | 47 | public func & ( 48 | lhs: @escaping FSMAction, 49 | rhs: @escaping FSMActionWithEvent 50 | ) -> [AnyAction] { 51 | [.init(lhs), .init(rhs)] 52 | } 53 | 54 | public func & ( 55 | lhs: @escaping FSMActionWithEvent, 56 | rhs: @escaping FSMAction 57 | ) -> [AnyAction] { 58 | [.init(lhs), .init(rhs)] 59 | } 60 | 61 | public func & ( 62 | lhs: @escaping FSMActionWithEvent, 63 | rhs: @escaping FSMActionWithEvent 64 | ) -> [AnyAction] { 65 | [.init(lhs), .init(rhs)] 66 | } 67 | 68 | // MARK: - Array convenience operators 69 | postfix operator * 70 | 71 | public postfix func * (_ value: @escaping FSMAction) -> [AnyAction] { 72 | Array(value) 73 | } 74 | 75 | public postfix func * ( 76 | _ value: @escaping FSMActionWithEvent 77 | ) -> [AnyAction] { 78 | Array(value) 79 | } 80 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/When.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Syntax { 4 | struct When { 5 | public static func | ( 6 | lhs: Self, 7 | rhs: Then 8 | ) -> MatchingWhenThen { 9 | .init(node: rhs.node.appending(lhs.node)) 10 | } 11 | 12 | public static func | ( 13 | lhs: Self, 14 | rhs: Then 15 | ) -> MatchingWhenThenActions { 16 | .init(node: ActionsNode(rest: [rhs.node.appending(lhs.node)])) 17 | } 18 | 19 | public static func | ( 20 | lhs: Self, 21 | rhs: @escaping FSMAction 22 | ) -> MatchingWhenActions { 23 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 24 | } 25 | 26 | public static func | ( 27 | lhs: Self, 28 | rhs: @escaping FSMActionWithEvent 29 | ) -> MatchingWhenActions { 30 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 31 | } 32 | 33 | public static func | ( 34 | lhs: Self, 35 | rhs: [AnyAction] 36 | ) -> MatchingWhenActions { 37 | .init(node: ActionsNode(actions: rhs, rest: [lhs.node])) 38 | } 39 | 40 | let node: WhenNode 41 | 42 | var blockNode: WhenBlockNode { 43 | WhenBlockNode(events: node.events, 44 | caller: node.caller, 45 | file: node.file, 46 | line: node.line) 47 | } 48 | 49 | init( 50 | _ events: [Event], 51 | file: String, 52 | line: Int 53 | ) { 54 | node = WhenNode( 55 | events: events.map { AnyTraceable($0, file: file, line: line) }, 56 | caller: "when", 57 | file: file, 58 | line: line 59 | ) 60 | } 61 | 62 | public func callAsFunction( 63 | @MTABuilder _ group: () -> [MatchingThenActions] 64 | ) -> MWTA_Group { 65 | .init(blockNode, group) 66 | } 67 | 68 | public func callAsFunction( 69 | @MABuilder _ group: () -> [MatchingActions] 70 | ) -> MWA_Group { 71 | .init(blockNode, group) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/FSM/LazyFSM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Algorithms 3 | 4 | extension FSM { 5 | class Lazy: Base { 6 | override func makeMatchResolvingNode( 7 | rest: [any SyntaxNode] 8 | ) -> any MatchResolvingNode { 9 | LazyMatchResolvingNode(rest: rest) 10 | } 11 | 12 | @discardableResult 13 | override func handleEvent( 14 | _ event: Event, 15 | predicates: [any Predicate], 16 | isolation: isolated (any Actor)? = #isolation 17 | ) async -> TransitionStatus { 18 | for combinations in makeCombinationsSequences(predicates) { 19 | for combination in combinations { 20 | let status = await super.handleEvent( 21 | event, 22 | predicates: combination, 23 | isolation: isolation 24 | ) 25 | 26 | if transitionWasFound(status) { 27 | logTransitionFound(status) 28 | return status 29 | } 30 | } 31 | } 32 | 33 | logTransitionNotFound(event, predicates) 34 | return .notFound(event, predicates) 35 | } 36 | 37 | private func makeCombinationsSequences( 38 | _ predicates: [any Predicate] 39 | ) -> [some Sequence<[any Predicate]>] { 40 | (0.. Bool { 48 | switch status { 49 | case .executed, .notExecuted: 50 | true 51 | case .notFound: 52 | false 53 | } 54 | } 55 | 56 | private func logTransitionFound(_ status: TransitionStatus) { 57 | if case let .executed(transition) = status { 58 | logTransitionExecuted(transition) 59 | } else if case let .notExecuted(transition) = status { 60 | logTransitionNotExecuted(transition) 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/ActionsResolvingNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct OverrideSyntaxDTO: Sendable { 4 | let state: AnyTraceable, 5 | descriptor: MatchDescriptorChain, 6 | event: AnyTraceable, 7 | nextState: AnyTraceable, 8 | actions: [AnyAction], 9 | overrideGroupID: UUID, 10 | isOverride: Bool 11 | 12 | init( 13 | _ state: AnyTraceable, 14 | _ match: MatchDescriptorChain, 15 | _ event: AnyTraceable, 16 | _ nextState: AnyTraceable, 17 | _ actions: [AnyAction], 18 | _ overrideGroupID: UUID = UUID(), 19 | _ isOverride: Bool = false 20 | ) { 21 | self.state = state 22 | self.descriptor = match 23 | self.event = event 24 | self.nextState = nextState 25 | self.actions = actions 26 | self.overrideGroupID = overrideGroupID 27 | self.isOverride = isOverride 28 | } 29 | } 30 | 31 | class ActionsResolvingNode: SyntaxNode { 32 | var rest: [any SyntaxNode] 33 | 34 | required init(rest: [any SyntaxNode] = []) { 35 | self.rest = rest 36 | } 37 | 38 | func combinedWith(_ rest: [DefineNode.Output]) -> [OverrideSyntaxDTO] { 39 | var onEntry = [AnyTraceable: [AnyAction]]() 40 | Set(rest.map(\.state)).forEach { state in 41 | onEntry[state] = rest.first { $0.state == state }?.onEntry 42 | } 43 | 44 | return rest.reduce(into: []) { 45 | let actions = shouldAddEntryExitActions($1) 46 | ? $1.actions + $1.onExit + (onEntry[$1.nextState] ?? []) 47 | : $1.actions 48 | 49 | $0.append( 50 | OverrideSyntaxDTO( 51 | $1.state, 52 | $1.match, 53 | $1.event, 54 | $1.nextState, 55 | actions, 56 | $1.overrideGroupID, 57 | $1.isOverride 58 | ) 59 | ) 60 | } 61 | } 62 | 63 | func shouldAddEntryExitActions(_ input: Input) -> Bool { 64 | input.state != input.nextState 65 | } 66 | } 67 | 68 | extension ActionsResolvingNode { 69 | final class OnStateChange: ActionsResolvingNode { } 70 | 71 | final class ExecuteAlways: ActionsResolvingNode { 72 | override func shouldAddEntryExitActions(_ input: Input) -> Bool { true } 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/NodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class NodeTests: XCTestCase { 5 | struct StringNode: SyntaxNode { 6 | let first: String 7 | var rest: [any SyntaxNode] 8 | 9 | func findErrors() -> [Error] { ["E"] } 10 | 11 | func combinedWith(_ rest: [String]) -> [String] { 12 | rest.reduce(into: []) { 13 | $0.append(first + $1) 14 | } ??? [first] 15 | } 16 | } 17 | 18 | 19 | func assertEqual( 20 | actual: ([T], [E])?, 21 | expected: ([T], [E])?, 22 | line: UInt = #line 23 | ) { 24 | XCTAssertEqual(actual?.0, expected?.0, line: line) 25 | XCTAssertEqual(actual?.1.map(\.localizedDescription), 26 | expected?.1.map(\.localizedDescription), line: line) 27 | } 28 | 29 | 30 | func testSafeNodesCallCombineWithRestRecursively() { 31 | let n0 = StringNode(first: "Then1", rest: []) 32 | let n1 = StringNode(first: "Then2", rest: []) 33 | let n2 = StringNode(first: "When", rest: [n0, n1]) 34 | let n3 = StringNode(first: "Given", rest: [n2]) 35 | 36 | assertEqual(actual: n3.resolve(), 37 | expected: (["GivenWhenThen1", "GivenWhenThen2"], 38 | ["E", "E", "E", "E"])) 39 | } 40 | 41 | // FIXME: Currently, there is a temporal coupling between Node.combinedWith() and Node.validate() - validate() cannot find all errors until combinedWith() has already been called. This test clarifies this arrangement until a better solution can be implemented. 42 | func testResolveCallsCombinedWithBeforeValidate() { 43 | class NodeSpy: SyntaxNode { 44 | var rest: [any SyntaxNode] = [] 45 | 46 | var log = [String]() 47 | 48 | func findErrors() -> [Error] { 49 | log.append("second call") 50 | return [] 51 | } 52 | 53 | func combinedWith(_ rest: [String]) -> [String] { 54 | log.append("first call") 55 | return [] 56 | } 57 | } 58 | 59 | let n = NodeSpy() 60 | let _ = n.resolve() 61 | XCTAssertEqual(n.log, ["first call", "second call"]) 62 | } 63 | } 64 | 65 | infix operator ???: AdditionPrecedence 66 | 67 | func ??? (lhs: T, rhs: T) -> T { 68 | lhs.isEmpty ? rhs : lhs 69 | } 70 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/SuperStateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class SuperStateTests: BlockTestsBase { 5 | func testSuperStateAddsSuperStateNodes() async { 6 | let s1 = SuperState { mwtaBlock } 7 | let nodes = SuperState(adopts: s1, s1).nodes 8 | 9 | XCTAssertEqual(4, nodes.count) 10 | await assertMWTAResult(Array(nodes.prefix(2)), sutLine: mwtaLine) 11 | await assertMWTAResult(Array(nodes.suffix(2)), sutLine: mwtaLine) 12 | } 13 | 14 | func testSuperStateSetsGroupIDForOwnNodesOnly() { 15 | let s1 = SuperState { 16 | when(1) | then(1) | pass 17 | } 18 | 19 | let s2 = SuperState(adopts: s1) { 20 | when(1) | then(2) | pass 21 | when(2) | then(3) | pass 22 | } 23 | 24 | assertGroupID(s2.nodes) 25 | } 26 | 27 | func testSuperStateCombinesSuperStateNodesParentFirst() async { 28 | let l1 = #line + 1; let s1 = SuperState { 29 | matching(P.a) | when(1, or: 2) | then(1) | pass 30 | when(1, or: 2) | then(1) | pass 31 | } 32 | 33 | let l2 = #line + 1; let s2 = SuperState(adopts: s1) { 34 | matching(P.a) | when(1, or: 2) | then(1) | pass 35 | when(1, or: 2) | then(1) | pass 36 | } 37 | 38 | let nodes = s2.nodes 39 | XCTAssertEqual(4, nodes.count) 40 | await assertMWTAResult(Array(nodes.prefix(2)), sutFile: #file, sutLine: l1) 41 | await assertMWTAResult(Array(nodes.suffix(2)), sutFile: #file, sutLine: l2) 42 | } 43 | 44 | func testSuperStateAddsEntryExitActions() async { 45 | let s1 = SuperState(onEntry: entry1, onExit: exit1) { mwtaBlock } 46 | let s2 = SuperState(adopts: s1) 47 | 48 | await assertActions(s2.onEntry, expectedOutput: "entry1") 49 | await assertActions(s2.onExit, expectedOutput: "exit1") 50 | } 51 | 52 | func testSuperStateCombinesEntryExitActions() async { 53 | let s1 = SuperState(onEntry: entry1, onExit: exit1) { mwtaBlock } 54 | let s2 = SuperState(adopts: s1, onEntry: entry2, onExit: exit2) { mwtaBlock } 55 | 56 | await assertActions(s2.onEntry, expectedOutput: "entry1entry2") 57 | await assertActions(s2.onExit, expectedOutput: "exit1exit2") 58 | } 59 | 60 | func testSuperStateBlock() async { 61 | let s = SuperState { mwtaBlock } 62 | await assertMWTAResult(s.nodes, sutLine: mwtaLine) 63 | } 64 | } 65 | 66 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/NodeConvenience.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol NeverEmptyNode: SyntaxNode { 4 | var caller: String { get } 5 | var file: String { get } 6 | var line: Int { get } 7 | } 8 | 9 | extension NeverEmptyNode { 10 | func findErrors() -> [Error] { 11 | makeError(if: rest.isEmpty) 12 | } 13 | 14 | func makeError(if predicate: Bool) -> [Error] { 15 | predicate ? [EmptyBuilderError(caller: caller, file: file, line: line)] : [] 16 | } 17 | } 18 | 19 | class OverridableNode { 20 | var overrideGroupID: UUID 21 | var isOverride: Bool 22 | 23 | init(overrideGroupID: UUID, isOverride: Bool) { 24 | self.overrideGroupID = overrideGroupID 25 | self.isOverride = isOverride 26 | } 27 | } 28 | 29 | struct RawSyntaxDTO: Sendable { 30 | let descriptor: MatchDescriptorChain, 31 | event: AnyTraceable?, 32 | state: AnyTraceable?, 33 | actions: [AnyAction], 34 | overrideGroupID: UUID, 35 | isOverride: Bool 36 | 37 | init( 38 | _ descriptor: MatchDescriptorChain, 39 | _ event: AnyTraceable?, 40 | _ state: AnyTraceable?, 41 | _ actions: [AnyAction], 42 | _ overrideGroupID: UUID = UUID(), 43 | _ isOverride: Bool = false 44 | ) { 45 | self.descriptor = descriptor 46 | self.event = event 47 | self.state = state 48 | self.actions = actions 49 | self.overrideGroupID = overrideGroupID 50 | self.isOverride = isOverride 51 | } 52 | } 53 | 54 | func makeDefaultIO( 55 | match: MatchDescriptorChain = MatchDescriptorChain(), 56 | event: AnyTraceable? = nil, 57 | state: AnyTraceable? = nil, 58 | actions: [AnyAction] = [], 59 | overrideGroupID: UUID = UUID(), 60 | isOverride: Bool = false 61 | ) -> [RawSyntaxDTO] { 62 | [RawSyntaxDTO(match, event, state, actions, overrideGroupID, isOverride)] 63 | } 64 | 65 | extension SyntaxNode { 66 | func appending(_ other: Other) -> Self where Input == Other.Output { 67 | var this = self 68 | this.rest = [other] 69 | return this 70 | } 71 | } 72 | 73 | extension Array { 74 | var nodes: [any SyntaxNode] { 75 | compactMap { ($0 as? Syntax.CompoundSyntax)?.node } 76 | } 77 | } 78 | 79 | extension String: @retroactive LocalizedError { 80 | public var errorDescription: String? { self } 81 | } 82 | 83 | infix operator ???: AdditionPrecedence 84 | 85 | func ??? (lhs: T, rhs: T) -> T { 86 | lhs.isEmpty ? rhs : lhs 87 | } 88 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/CompoundSyntax.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Syntax { 4 | public class CompoundSyntax { 5 | let node: any SyntaxNode 6 | 7 | init(node: any SyntaxNode) { 8 | self.node = node 9 | } 10 | } 11 | 12 | protocol CompoundSyntaxGroup { 13 | var node: any SyntaxNode { get } 14 | 15 | init(node: any SyntaxNode) 16 | } 17 | 18 | public final class MatchingWhen: CompoundSyntax { } 19 | public final class MatchingThen: CompoundSyntax { } 20 | public final class MatchingWhenThen: CompoundSyntax { } 21 | 22 | public class MatchingActions: CompoundSyntax { } 23 | public class MatchingWhenActions: CompoundSyntax { } 24 | public class MatchingThenActions: CompoundSyntax { } 25 | public class MatchingWhenThenActions: CompoundSyntax { } 26 | 27 | public final class MWA_Group: MatchingWhenActions, CompoundSyntaxGroup { } 28 | public final class MTA_Group: MatchingThenActions, CompoundSyntaxGroup { } 29 | public final class MWTA_Group: MatchingWhenThenActions, CompoundSyntaxGroup { } 30 | 31 | @resultBuilder public struct MWTABuilder: ResultBuilder { 32 | public typealias T = MatchingWhenThenActions 33 | } 34 | 35 | @resultBuilder public struct MWABuilder: ResultBuilder { 36 | public typealias T = MatchingWhenActions 37 | } 38 | 39 | @resultBuilder public struct MTABuilder: ResultBuilder { 40 | public typealias T = MatchingThenActions 41 | } 42 | 43 | @resultBuilder public struct MABuilder: ResultBuilder { 44 | public typealias T = MatchingActions 45 | } 46 | } 47 | 48 | extension Syntax.CompoundSyntaxGroup { 49 | init>( 50 | _ n: N, 51 | _ group: () -> [Syntax.CompoundSyntax] 52 | ) where N.Input == N.Output { 53 | var n = n 54 | n.rest = group().nodes 55 | self.init(node: n) 56 | } 57 | 58 | init( 59 | _ actions: [AnyAction], 60 | file: String = #file, 61 | line: Int = #line, 62 | _ group: () -> [Syntax.CompoundSyntax] 63 | ) { 64 | self.init( 65 | node: ActionsBlockNode( 66 | actions: actions, 67 | rest: group().nodes, 68 | caller: "actions", 69 | file: file, 70 | line: line 71 | ) 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSMValue/FSMValue+Numbers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol FSMFloat { } 4 | extension Float: FSMFloat { } 5 | extension Double: FSMFloat { } 6 | 7 | public protocol FSMInt { } 8 | extension Int8: FSMInt { } 9 | extension Int: FSMInt { } 10 | extension Int16: FSMInt { } 11 | extension Int32: FSMInt { } 12 | extension Int64: FSMInt { } 13 | 14 | extension FSMValue: ExpressibleByIntegerLiteral where T: FSMInt { 15 | public init(integerLiteral value: Int) { 16 | self = switch T.self { 17 | case is Int8.Type: .some(Int8(value) as! T) 18 | case is Int16.Type: .some(Int16(value) as! T) 19 | case is Int32.Type: .some(Int32(value) as! T) 20 | case is Int64.Type: .some(Int64(value) as! T) 21 | default: .some(value as! T) 22 | } 23 | } 24 | } 25 | 26 | extension FSMValue: ExpressibleByFloatLiteral where T: FSMFloat { 27 | public init(floatLiteral value: Double) { 28 | self = switch T.self { 29 | case is Float.Type: .some(Float(value) as! T) 30 | default: .some(value as! T) 31 | } 32 | } 33 | } 34 | 35 | extension FSMValue where T: AdditiveArithmetic { 36 | public static func + (lhs: Self, rhs: T) -> T { 37 | lhs.unsafeWrappedValue() + rhs 38 | } 39 | 40 | public static func + (lhs: T, rhs: Self) -> T { 41 | lhs + rhs.unsafeWrappedValue() 42 | } 43 | 44 | public static func - (lhs: Self, rhs: T) -> T { 45 | lhs.unsafeWrappedValue() - rhs 46 | } 47 | 48 | public static func - (lhs: T, rhs: Self) -> T { 49 | lhs - rhs.unsafeWrappedValue() 50 | } 51 | } 52 | 53 | extension FSMValue where T: Numeric { 54 | public static func * (lhs: Self, rhs: T) -> T { 55 | lhs.unsafeWrappedValue() * rhs 56 | } 57 | 58 | public static func * (lhs: T, rhs: Self) -> T { 59 | lhs * rhs.unsafeWrappedValue() 60 | } 61 | } 62 | 63 | extension FSMValue where T: BinaryInteger { 64 | public static func / (lhs: Self, rhs: T) -> T { 65 | lhs.unsafeWrappedValue() / rhs 66 | } 67 | 68 | public static func / (lhs: T, rhs: Self) -> T { 69 | lhs / rhs.unsafeWrappedValue() 70 | } 71 | 72 | public static func % (lhs: Self, rhs: T) -> T { 73 | lhs.unsafeWrappedValue() % rhs 74 | } 75 | 76 | public static func % (lhs: T, rhs: Self) -> T { 77 | lhs % rhs.unsafeWrappedValue() 78 | } 79 | } 80 | 81 | extension FSMValue where T: FloatingPoint { 82 | public static func / (lhs: Self, rhs: T) -> T { 83 | lhs.unsafeWrappedValue() / rhs 84 | } 85 | 86 | public static func / (lhs: T, rhs: Self) -> T { 87 | lhs / rhs.unsafeWrappedValue() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/DefineNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class DefineNode: NeverEmptyNode { 4 | struct Output { 5 | let state: AnyTraceable, 6 | match: MatchDescriptorChain, 7 | event: AnyTraceable, 8 | nextState: AnyTraceable, 9 | actions: [AnyAction], 10 | onEntry: [AnyAction], 11 | onExit: [AnyAction], 12 | overrideGroupID: UUID, 13 | isOverride: Bool 14 | 15 | init(_ state: AnyTraceable, 16 | _ match: MatchDescriptorChain, 17 | _ event: AnyTraceable, 18 | _ nextState: AnyTraceable, 19 | _ actions: [AnyAction], 20 | _ onEntry: [AnyAction], 21 | _ onExit: [AnyAction], 22 | _ overrideGroupID: UUID, 23 | _ isOverride: Bool 24 | ) { 25 | self.state = state 26 | self.match = match 27 | self.event = event 28 | self.nextState = nextState 29 | self.actions = actions 30 | self.onEntry = onEntry 31 | self.onExit = onExit 32 | self.overrideGroupID = overrideGroupID 33 | self.isOverride = isOverride 34 | } 35 | } 36 | 37 | let onEntry: [AnyAction] 38 | let onExit: [AnyAction] 39 | var rest: [any SyntaxNode] = [] 40 | 41 | let caller: String 42 | let file: String 43 | let line: Int 44 | 45 | private var errors: [Error] = [] 46 | 47 | init( 48 | onEntry: [AnyAction], 49 | onExit: [AnyAction], 50 | rest: [any SyntaxNode] = [], 51 | caller: String = #function, 52 | file: String = #file, 53 | line: Int = #line 54 | ) { 55 | self.onEntry = onEntry 56 | self.onExit = onExit 57 | self.rest = rest 58 | self.caller = caller 59 | self.file = file 60 | self.line = line 61 | } 62 | 63 | func combinedWith(_ rest: [GivenNode.Output]) -> [Output] { 64 | let output = rest.reduce(into: [Output]()) { 65 | if let descriptor = finalise($1.descriptor) { 66 | $0.append( 67 | Output($1.state, 68 | descriptor, 69 | $1.event, 70 | $1.nextState, 71 | $1.actions, 72 | onEntry, 73 | onExit, 74 | $1.overrideGroupID, 75 | $1.isOverride) 76 | ) 77 | } 78 | } 79 | 80 | return errors.isEmpty ? output : [] 81 | } 82 | 83 | private func finalise(_ m: MatchDescriptorChain) -> MatchDescriptorChain? { 84 | switch m.resolve() { 85 | case .failure(let e): errors.append(e); return nil 86 | case .success(let m): return m 87 | } 88 | } 89 | 90 | func findErrors() -> [Error] { 91 | makeError(if: rest.isEmpty) + errors 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/Define.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Syntax { 4 | struct Define { 5 | let node: DefineNode 6 | 7 | init( 8 | state: State, 9 | adopts superStates: [SuperState], 10 | onEntry: [AnyAction], 11 | onExit: [AnyAction], 12 | file: String = #file, 13 | line: Int = #line, 14 | @MWTABuilder _ group: () -> [MatchingWhenThenActions] 15 | ) { 16 | let elements = group() 17 | 18 | self.init( 19 | state, 20 | adopts: elements.isEmpty ? [] : superStates, 21 | onEntry: onEntry, 22 | onExit: onExit, 23 | elements: elements, 24 | file: file, 25 | line: line 26 | ) 27 | } 28 | 29 | init( 30 | _ state: State, 31 | adopts superStates: [SuperState], 32 | onEntry: [AnyAction], 33 | onExit: [AnyAction], 34 | elements: [MatchingWhenThenActions], 35 | file: String = #file, 36 | line: Int = #line 37 | ) { 38 | let onEntry = superStates.entryActions + onEntry 39 | let onExit = superStates.exitActions + onExit 40 | 41 | let dNode = DefineNode( 42 | onEntry: onEntry, 43 | onExit: onExit, 44 | caller: "define", 45 | file: file, 46 | line: line 47 | ) 48 | 49 | let isValid = !superStates.isEmpty || !elements.isEmpty 50 | 51 | if isValid { 52 | dNode.setUp( 53 | givenState: state, 54 | superStateNodes: superStates.nodes, 55 | defineNodes: elements.nodes.withOverrideGroupID(), 56 | file: file, 57 | line: line 58 | ) 59 | } 60 | 61 | self.node = dNode 62 | } 63 | } 64 | } 65 | 66 | private extension DefineNode { 67 | func setUp( 68 | givenState: S, 69 | superStateNodes: [any SyntaxNode], 70 | defineNodes: [any SyntaxNode], 71 | file: String, 72 | line: Int 73 | ) { 74 | let traceableState = AnyTraceable(givenState, file: file, line: line) 75 | let rest = superStateNodes + defineNodes 76 | let gNode = GivenNode(states: [traceableState], rest: rest) 77 | self.rest = [gNode] 78 | } 79 | } 80 | 81 | private extension Array { 82 | var nodes: [any SyntaxNode] { 83 | map(\.nodes).flattened 84 | } 85 | 86 | var entryActions: [AnyAction] { 87 | actions(\.onEntry) 88 | } 89 | 90 | var exitActions: [AnyAction] { 91 | actions(\.onExit) 92 | } 93 | 94 | func actions(_ transform: (SuperState) -> [AnyAction]) -> [AnyAction] { 95 | map(transform).flattened 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/ManualTests.swift: -------------------------------------------------------------------------------- 1 | #if(false) 2 | @testable import SwiftFSM 3 | 4 | class ManualTests: ExpandedSyntaxBuilder { 5 | typealias State = Int 6 | typealias Event = Int 7 | 8 | enum A: Predicate { case x } 9 | enum B: Predicate { case x } 10 | 11 | func function() { } 12 | 13 | func orTypesMustBeTheSame() { 14 | _ = matching(A.x, or: B.x) 15 | } 16 | 17 | func cannotChainWhens1() { 18 | define(1) { 19 | when(1) { 20 | when(1) { } 21 | } 22 | } 23 | } 24 | 25 | func cannotChainWhens2() { 26 | define(1) { 27 | when(1) { 28 | when(1) | then() 29 | } 30 | } 31 | } 32 | 33 | func cannotChainWhens3() { 34 | define(1) { 35 | when(1) { 36 | matching(A.x) | when(1) | function 37 | } 38 | } 39 | } 40 | 41 | func cannotChainWhens4() { 42 | define(1) { 43 | when(1) { 44 | matching(A.x) { 45 | when(1) { } 46 | } 47 | } 48 | } 49 | } 50 | 51 | func cannotChainWhens5() { 52 | define(1) { 53 | when(1) { 54 | matching(A.x) { 55 | when(1) | then() 56 | } 57 | } 58 | } 59 | } 60 | 61 | func cannotChainThens1() { 62 | define(1) { 63 | then(1) { 64 | then(1) { } 65 | } 66 | } 67 | } 68 | 69 | func cannotChainThens2() { 70 | define(1) { 71 | then(1) { 72 | then(1) | then() 73 | } 74 | } 75 | } 76 | 77 | func cannotChainThens3() { 78 | define(1) { 79 | then(1) { 80 | matching(A.x) | then(1) | function 81 | } 82 | } 83 | } 84 | 85 | func cannotChainThens4() { 86 | define(1) { 87 | then(1) { 88 | matching(A.x) { 89 | then(1) { } 90 | } 91 | } 92 | } 93 | } 94 | 95 | func cannotChainThens5() { 96 | define(1) { 97 | then(1) { 98 | matching(A.x) { 99 | then(1) | then() 100 | } 101 | } 102 | } 103 | } 104 | 105 | func cannotWhenMultipleThen() { 106 | define(1) { 107 | when(2) { 108 | then(.unlocked) 109 | then(1) 110 | then(.unlocked) | function 111 | then(1) | function 112 | } 113 | } 114 | } 115 | 116 | func invalidBlocksAfterPipes1() { 117 | define(1) { 118 | when(1) | then(1) { } 119 | } 120 | } 121 | 122 | func invalidBlocksAfterPipes2() { 123 | define(1) { 124 | when(1) | then(1) | actions(function) { } 125 | } 126 | } 127 | 128 | func invalidBlocksAfterPipes3() { 129 | define(1) { 130 | matching(A.x) | when(1) { } 131 | } 132 | } 133 | 134 | #warning("this seems to compile when it shouldn't") 135 | func noControlLogicInBuilders() { 136 | define(1) { 137 | if(false) { 138 | when(2) | then() 139 | } else { 140 | when(2) | then() 141 | } 142 | } 143 | } 144 | } 145 | 146 | #endif 147 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/GivenNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class GivenNodeTests: SyntaxNodeTests { 5 | func testEmptyGivenNode() { 6 | assertEmptyNodeWithoutError(GivenNode(states: [], rest: [])) 7 | } 8 | 9 | func testGivenNodeWithEmptyStates() { 10 | assertEmptyNodeWithoutError(GivenNode(states: [], rest: [whenNode])) 11 | } 12 | 13 | func testGivenNodeWithEmptyRest() { 14 | assertEmptyNodeWithoutError(GivenNode(states: [s1, s2], rest: [])) 15 | } 16 | 17 | func testGivenNodeFinalisesFillingInEmptyNextStates() async { 18 | let expected = [MSES(m1, s1, e1, s1), 19 | MSES(m1, s1, e2, s1), 20 | MSES(m1, s2, e1, s2), 21 | MSES(m1, s2, e2, s2)] 22 | 23 | await assertGivenNode(expected: expected, 24 | actionsOutput: "12121212", 25 | node: givenNode(thenState: nil, actionsNode: actionsNode)) 26 | } 27 | 28 | func testGivenNodeFinalisesWithNextStates() async { 29 | let expected = [MSES(m1, s1, e1, s3), 30 | MSES(m1, s1, e2, s3), 31 | MSES(m1, s2, e1, s3), 32 | MSES (m1, s2, e2, s3)] 33 | 34 | await assertGivenNode(expected: expected, 35 | actionsOutput: "12121212", 36 | node: givenNode(thenState: s3, actionsNode: actionsNode)) 37 | } 38 | 39 | func testGivenNodeCanSetRestAfterInitialisation() async { 40 | let t = ThenNode(state: s3, rest: [actionsNode]) 41 | let w = WhenNode(events: [e1, e2], rest: [t]) 42 | let m = MatchingNode(descriptor: m1, rest: [w]) 43 | var g = GivenNode(states: [s1, s2]) 44 | g.rest.append(m) 45 | 46 | let expected = [MSES(m1, s1, e1, s3), 47 | MSES(m1, s1, e2, s3), 48 | MSES(m1, s2, e1, s3), 49 | MSES(m1, s2, e2, s3)] 50 | 51 | await assertGivenNode(expected: expected, 52 | actionsOutput: "12121212", 53 | node: g) 54 | } 55 | 56 | func testGivenNodeWithMultipleWhenNodes() async { 57 | let t = ThenNode(state: s3, rest: [actionsNode]) 58 | let w = WhenNode(events: [e1, e2], rest: [t]) 59 | let m = MatchingNode(descriptor: m1, rest: [w, w]) 60 | let g = GivenNode(states: [s1, s2], rest: [m]) 61 | 62 | let expected = [MSES(m1, s1, e1, s3), 63 | MSES(m1, s1, e2, s3), 64 | MSES(m1, s1, e1, s3), 65 | MSES(m1, s1, e2, s3), 66 | MSES(m1, s2, e1, s3), 67 | MSES(m1, s2, e2, s3), 68 | MSES(m1, s2, e1, s3), 69 | MSES(m1, s2, e2, s3)] 70 | 71 | await assertGivenNode(expected: expected, 72 | actionsOutput: "1212121212121212", 73 | node: g) 74 | } 75 | 76 | func testGivenNodePassesGroupIDAndIsOverrideParams() { 77 | let t = ThenNode(state: s3, rest: [actionsNode]) 78 | let w = WhenNode(events: [e1], rest: [t]) 79 | let m = MatchingNode(descriptor: m1, rest: [w], overrideGroupID: testGroupID, isOverride: true) 80 | let output = GivenNode(states: [s1], rest: [m]).resolve().output 81 | 82 | XCTAssert(output.allSatisfy { $0.overrideGroupID == testGroupID && $0.isOverride == true }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/AnyActionSyntaxTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class AnyActionSyntaxTests: AnyActionTestsBase { 5 | func assertAsync(_ actions: [AnyAction], expected: String, line: UInt = #line) async { 6 | for a in actions { 7 | await a("event") 8 | } 9 | assert(actions, expected: expected, line: line) 10 | } 11 | 12 | func assert(_ actions: [AnyAction], expected: String, line: UInt = #line) { 13 | XCTAssertEqual(output, expected, line: line) 14 | output = "" 15 | } 16 | 17 | func testCanMakeAnyActionsArray() async { 18 | await assertAsync(AnyAction(pass) & passAsync, expected: "passpass") 19 | await assertAsync(AnyAction(pass) & passWithEventAsync, expected: "passevent") 20 | } 21 | 22 | func testCombinesAnyActionsArrays() async { 23 | let a = AnyAction(pass) & pass 24 | 25 | await assertAsync(a & passAsync, expected: "passpasspass") 26 | await assertAsync(a & passWithEventAsync, expected: "passpassevent") 27 | } 28 | 29 | func testOperatorChains() async { 30 | await assertAsync(AnyAction(pass) & pass & passAsync, expected: "passpasspass") 31 | await assertAsync(AnyAction(pass) & pass & passWithEventAsync, expected: "passpassevent") 32 | } 33 | 34 | func testCombinesRawActionsToFormAnyActions() async { 35 | await assertAsync(pass & passAsync, expected: "passpass") 36 | await assertAsync(pass & passWithEventAsync, expected: "passevent") 37 | 38 | await assertAsync(passWithEvent & passAsync, expected: "eventpass") 39 | await assertAsync(passWithEvent & passWithEventAsync, expected: "eventevent") 40 | 41 | await assertAsync(passAsync & pass, expected: "passpass") 42 | await assertAsync(passAsync & passWithEvent, expected: "passevent") 43 | await assertAsync(passAsync & passAsync, expected: "passpass") 44 | await assertAsync(passAsync & passWithEventAsync, expected: "passevent") 45 | 46 | await assertAsync(passWithEventAsync & pass, expected: "eventpass") 47 | await assertAsync(passWithEventAsync & passWithEvent, expected: "eventevent") 48 | await assertAsync(passWithEventAsync & passAsync, expected: "eventpass") 49 | await assertAsync(passWithEventAsync & passWithEventAsync, expected: "eventevent") 50 | } 51 | 52 | func testFormsArrayWithSingleAction() async { 53 | await assertAsync(Array(passAsync), expected: "pass") 54 | await assertAsync(passAsync*, expected: "pass") 55 | await assertAsync(Array(passWithEventAsync), expected: "event") 56 | await assertAsync(passWithEventAsync*, expected: "event") 57 | } 58 | 59 | func passWithStringSync(_ s: String) { } 60 | func passWithStringAsync(_ s: String) async { } 61 | func passWithIntSync(_ i: Int) { } 62 | func passWithIntAsync(_ i: Int) { } 63 | 64 | func testHandlesMixedEventTypes() { 65 | let a = AnyAction(passWithStringSync) & passWithIntSync 66 | let b = AnyAction(passWithStringAsync) & passWithIntAsync 67 | let c = AnyAction(passWithStringSync) & passWithIntAsync 68 | let d = AnyAction(passWithStringAsync) & passWithIntSync 69 | 70 | let _ = a & passWithStringSync 71 | let _ = b & passWithStringAsync 72 | let _ = c & passWithStringSync 73 | let _ = d & passWithStringAsync 74 | 75 | let _ = passWithStringSync & passWithIntSync 76 | let _ = passWithStringAsync & passWithIntSync 77 | let _ = passWithStringAsync & passWithIntAsync 78 | let _ = passWithStringSync & passWithIntAsync 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/swift-fsm.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 48 | 49 | 50 | 51 | 54 | 60 | 61 | 62 | 63 | 64 | 74 | 75 | 81 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/MatchResolvingNode/MRNTestBase.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class MRNTestBase: StringableNodeTest { 5 | typealias ARN = ActionsResolvingNode.OnStateChange 6 | typealias EMRN = EagerMatchResolvingNode 7 | typealias SVN = SemanticValidationNode 8 | typealias MRNResult = (output: [Transition], errors: [Error]) 9 | 10 | struct ExpectedMRNOutput { 11 | let condition: Bool?, 12 | state: AnyHashable, 13 | match: MatchDescriptorChain, 14 | predicates: PredicateSet, 15 | event: AnyHashable, 16 | nextState: AnyHashable, 17 | actionsOutput: String 18 | 19 | init( 20 | condition: Bool? = false, 21 | state: AnyHashable, 22 | match: MatchDescriptorChain, 23 | predicates: PredicateSet, 24 | event: AnyHashable, 25 | nextState: AnyHashable, 26 | actionsOutput: String 27 | ) { 28 | self.condition = condition 29 | self.state = state 30 | self.match = match 31 | self.predicates = predicates 32 | self.event = event 33 | self.nextState = nextState 34 | self.actionsOutput = actionsOutput 35 | } 36 | } 37 | 38 | func makeOutput( 39 | c: Bool? = false, 40 | g: AnyTraceable, 41 | m: MatchDescriptorChain, 42 | p: [any Predicate], 43 | w: AnyTraceable, 44 | t: AnyTraceable, 45 | a: String = "12" 46 | ) -> ExpectedMRNOutput { 47 | .init(condition: c, 48 | state: g.base, 49 | match: m, 50 | predicates: Set(p.erased()), 51 | event: w.base, 52 | nextState: t.base, 53 | actionsOutput: a) 54 | } 55 | 56 | func makeOutput( 57 | c: Bool? = false, 58 | g: AnyTraceable, 59 | m: MatchDescriptorChain, 60 | p: Set, 61 | w: AnyTraceable, 62 | t: AnyTraceable, 63 | a: String = "12" 64 | ) -> ExpectedMRNOutput { 65 | .init(condition: c, 66 | state: g.base, 67 | match: m, 68 | predicates: p, 69 | event: w.base, 70 | nextState: t.base, 71 | actionsOutput: a) 72 | } 73 | 74 | func assertResult( 75 | _ result: MRNResult, 76 | expected: ExpectedMRNOutput, 77 | file: StaticString = #filePath, 78 | line: UInt = #line 79 | ) async { 80 | assertCount(result.errors, expected: 0, file: file, line: line) 81 | 82 | await assertEqual(expected, result.output.first { 83 | $0.state == expected.state && 84 | $0.predicates == expected.predicates && 85 | $0.event == expected.event && 86 | $0.nextState == expected.nextState 87 | }, file: file, line: line) 88 | } 89 | 90 | func assertEqual( 91 | _ lhs: ExpectedMRNOutput?, 92 | _ rhs: Transition?, 93 | file: StaticString = #filePath, 94 | line: UInt = #line 95 | ) async { 96 | let condition = rhs?.condition?() 97 | XCTAssertEqual(lhs?.condition, condition, file: file, line: line) 98 | XCTAssertEqual(lhs?.state, rhs?.state, file: file, line: line) 99 | XCTAssertEqual(lhs?.predicates, rhs?.predicates, file: file, line: line) 100 | XCTAssertEqual(lhs?.event, rhs?.event, file: file, line: line) 101 | XCTAssertEqual(lhs?.nextState, rhs?.nextState, file: file, line: line) 102 | 103 | await assertActions( 104 | rhs?.actions, 105 | expectedOutput: lhs?.actionsOutput, 106 | file: file, 107 | line: line 108 | ) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Syntax/Matching.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias ConditionProvider = @Sendable () -> Bool 4 | 5 | public extension Syntax { 6 | protocol Conditional { 7 | associatedtype State: FSMHashable 8 | associatedtype Event: FSMHashable 9 | } 10 | 11 | internal protocol _Conditional: Conditional { 12 | var node: MatchingNode { get } 13 | var file: String { get } 14 | var line: Int { get } 15 | var name: String { get } 16 | } 17 | 18 | struct Condition: _Conditional { 19 | let node: MatchingNode 20 | let file: String 21 | let line: Int 22 | 23 | var name: String { "condition" } 24 | 25 | init( 26 | _ condition: @escaping ConditionProvider, 27 | file: String = #file, 28 | line: Int = #line 29 | ) { 30 | let match = MatchDescriptorChain( 31 | condition: condition, 32 | file: file, 33 | line: line 34 | ) 35 | self.node = MatchingNode(descriptor: match, rest: []) 36 | self.file = file 37 | self.line = line 38 | } 39 | } 40 | 41 | struct Matching: _Conditional { 42 | let node: MatchingNode 43 | let file: String 44 | let line: Int 45 | 46 | var name: String { "matching" } 47 | 48 | init( 49 | _ predicate: P, 50 | or: [P], 51 | and: [any Predicate], 52 | file: String = #file, 53 | line: Int = #line 54 | ) { 55 | if or.isEmpty { 56 | self.init( 57 | any: [], all: [predicate] + and, 58 | file: file, 59 | line: line 60 | ) 61 | } else { 62 | self.init( 63 | any: [predicate] + or, 64 | all: and, 65 | file: file, 66 | line: line 67 | ) 68 | } 69 | } 70 | 71 | private init( 72 | any: [any Predicate], 73 | all: [any Predicate], 74 | file: String = #file, 75 | line: Int = #line 76 | ) { 77 | let match = MatchDescriptorChain( 78 | any: any.erased(), 79 | all: all.erased(), 80 | file: file, 81 | line: line 82 | ) 83 | 84 | self.node = MatchingNode(descriptor: match, rest: []) 85 | self.file = file 86 | self.line = line 87 | } 88 | } 89 | } 90 | 91 | extension Syntax.Conditional { 92 | var node: MatchingNode { this.node } 93 | var file: String { this.file } 94 | var line: Int { this.line } 95 | var name: String { this.name } 96 | 97 | var this: any Syntax._Conditional { self as! any Syntax._Conditional } 98 | 99 | var blockNode: MatchingBlockNode { 100 | MatchingBlockNode( 101 | descriptor: node.descriptor, 102 | rest: node.rest, 103 | caller: name, 104 | file: file, 105 | line: line 106 | ) 107 | } 108 | 109 | public func callAsFunction( 110 | @Syntax.MWTABuilder _ group: () -> [Syntax.MatchingWhenThenActions] 111 | ) -> Syntax.MWTA_Group { 112 | .init(blockNode, group) 113 | } 114 | 115 | public func callAsFunction( 116 | @Syntax.MWABuilder _ group: () -> [Syntax.MatchingWhenActions] 117 | ) -> Syntax.MWA_Group { 118 | .init(blockNode, group) 119 | } 120 | 121 | public func callAsFunction( 122 | @Syntax.MTABuilder _ group: () -> [Syntax.MatchingThenActions] 123 | ) -> Syntax.MTA_Group { 124 | .init(blockNode, group) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/ActionsResolvingNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class ActionsResolvingNodeTests: DefineConsumer { 5 | func testEmptyNode() { 6 | let node = ActionsResolvingNode.OnStateChange() 7 | let finalised = node.resolve() 8 | XCTAssertTrue(finalised.output.isEmpty) 9 | XCTAssertTrue(finalised.errors.isEmpty) 10 | } 11 | 12 | func assertNode( 13 | type: T.Type, 14 | g: AnyTraceable, 15 | m: MatchDescriptorChain, 16 | w: AnyTraceable, 17 | t: AnyTraceable, 18 | output: String, 19 | line: UInt = #line 20 | ) async { 21 | let node = T.init(rest: [defineNode(g, m, w, t, exit: onExit)]) 22 | let finalised = node.resolve() 23 | XCTAssertTrue(finalised.errors.isEmpty, line: line) 24 | guard assertCount(finalised.output, expected: 1, line: line) else { return } 25 | 26 | let result = finalised.output[0] 27 | await assertResult(result, g, m, w, t, output, line) 28 | } 29 | 30 | func assertResult( 31 | _ result: ActionsResolvingNode.OnStateChange.Output, 32 | _ g: AnyTraceable, 33 | _ m: MatchDescriptorChain, 34 | _ w: AnyTraceable, 35 | _ t: AnyTraceable, 36 | _ output: String, 37 | _ line: UInt = #line 38 | ) async { 39 | XCTAssertEqual(result.state, g, line: line) 40 | XCTAssertEqual(result.descriptor, m, line: line) 41 | XCTAssertEqual(result.event, w, line: line) 42 | XCTAssertEqual(result.nextState, t, line: line) 43 | XCTAssertEqual(result.overrideGroupID, testGroupID, line: line) 44 | XCTAssertEqual(result.isOverride, false, line: line) 45 | 46 | await assertActions(result.actions, expectedOutput: output, line: line) 47 | } 48 | 49 | let m = MatchDescriptorChain() 50 | 51 | func testConditionalDoesNotAddExitActionsWithoutStateChange() async { 52 | await assertNode(type: ActionsResolvingNode.OnStateChange.self, 53 | g: s1, m: m, w: e1, t: s1, output: "12") 54 | } 55 | 56 | func testUnconditionalAddsExitActionsWithoutStateChange() async { 57 | await assertNode(type: ActionsResolvingNode.ExecuteAlways.self, 58 | g: s1, m: m, w: e1, t: s1, output: "12>>") 59 | } 60 | 61 | func testConditionalAddsExitActionsWithStateChange() async { 62 | await assertNode(type: ActionsResolvingNode.OnStateChange.self, 63 | g: s1, m: m, w: e1, t: s2, output: "12>>") 64 | } 65 | 66 | func testConditionalDoesNotAddEntryActionsWithoutStateChange() async { 67 | let d1 = defineNode(s1, m, e1, s1, entry: onEntry, exit: []) 68 | let result = ActionsResolvingNode.OnStateChange(rest: [d1]).resolve() 69 | 70 | XCTAssertTrue(result.errors.isEmpty) 71 | guard assertCount(result.output, expected: 1) else { return } 72 | 73 | await assertResult(result.output[0], s1, m, e1, s1, "12") 74 | } 75 | 76 | func testUnconditionalAddsEntryActionsWithoutStateChange() async { 77 | let d1 = defineNode(s1, m, e1, s1, entry: onEntry, exit: onExit) 78 | let result = ActionsResolvingNode.ExecuteAlways(rest: [d1]).resolve() 79 | 80 | XCTAssertTrue(result.errors.isEmpty) 81 | guard assertCount(result.output, expected: 1) else { return } 82 | 83 | await assertResult(result.output[0], s1, m, e1, s1, "12>><<") 84 | } 85 | 86 | func testConditionalAddsEntryActionsForStateChange() async { 87 | let d1 = defineNode(s1, m, e1, s2) 88 | let d2 = defineNode(s2, m, e1, s3, entry: onEntry, exit: onExit) 89 | let result = ActionsResolvingNode.OnStateChange(rest: [d1, d2]).resolve() 90 | 91 | XCTAssertTrue(result.errors.isEmpty) 92 | guard assertCount(result.output, expected: 2) else { return } 93 | 94 | await assertResult(result.output[0], s1, m, e1, s2, "12<<") 95 | await assertResult(result.output[1], s2, m, e1, s3, "12>>") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/MatchResolvingNode/LazyMRNTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class LazyMatchResolvingNodeTests: MRNTestBase { 5 | typealias LMRN = LazyMatchResolvingNode 6 | 7 | func makeSUT(rest: [any SyntaxNode]) -> LMRN { 8 | .init(rest: [SVN(rest: [ARN(rest: rest)])]) 9 | } 10 | 11 | func assertNotMatchClash( 12 | _ m1: MatchDescriptorChain, 13 | _ m2: MatchDescriptorChain, 14 | line: UInt = #line 15 | ) async { 16 | let d1 = defineNode(s1, m1, e1, s2) 17 | let d2 = defineNode(s1, m2, e1, s3) 18 | 19 | let p1 = m1.combineAnyAndAll().first ?? [] 20 | let p2 = m2.combineAnyAndAll().first ?? [] 21 | 22 | let result = makeSUT(rest: [d1, d2]).resolve() 23 | 24 | guard 25 | assertCount(result.errors, expected: 0, line: line), 26 | assertCount(result.output, expected: 2, line: line) 27 | else { return } 28 | 29 | await assertEqual( 30 | makeOutput(c: nil, g: s1, m: m1, p: p1, w: e1, t: s2), 31 | result.output.first, 32 | line: line 33 | ) 34 | 35 | await assertEqual( 36 | makeOutput(c: nil, g: s1, m: m2, p: p2, w: e1, t: s3), 37 | result.output.last, 38 | line: line 39 | ) 40 | } 41 | 42 | func assertMatchClash(_ m1: MatchDescriptorChain, _ m2: MatchDescriptorChain, line: UInt = #line) { 43 | let d1 = defineNode(s1, m1, e1, s2) 44 | let d2 = defineNode(s1, m2, e1, s3) 45 | let finalised = makeSUT(rest: [d1, d2]).resolve() 46 | 47 | guard 48 | assertCount(finalised.output, expected: 0, line: line), 49 | assertCount(finalised.errors, expected: 1, line: line) 50 | else { return } 51 | 52 | XCTAssert(finalised.errors.first is EMRN.ImplicitClashesError, line: line) 53 | } 54 | 55 | func testInit() async { 56 | let sut = makeSUT(rest: [defineNode(s1, m1, e1, s2)]) 57 | let rest = SVN(rest: [ARN(rest: [defineNode(s1, m1, e1, s2)])]) 58 | await assertEqualFileAndLine(rest, sut.rest.first!) 59 | } 60 | 61 | func testEmptyMatchOutput() async { 62 | let sut = makeSUT(rest: [defineNode(s1, MatchDescriptorChain(), e1, s2)]) 63 | let result = sut.resolve() 64 | 65 | assertCount(result.errors, expected: 0) 66 | assertCount(result.output, expected: 1) 67 | 68 | await assertEqual( 69 | makeOutput( 70 | c: nil, g: s1, m: MatchDescriptorChain(), p: [], w: e1, t: s2 71 | ), 72 | result.output.first 73 | ) 74 | } 75 | 76 | func testPredicateMatchOutput() async { 77 | let sut = makeSUT(rest: [defineNode(s1, m1, e1, s2)]) 78 | let result = sut.resolve() 79 | 80 | assertCount(result.errors, expected: 0) 81 | assertCount(result.output, expected: 1) 82 | 83 | await assertEqual( 84 | makeOutput( 85 | g: s1, m: m1, p: [P.a, Q.a], w: e1, t: s2 86 | ), 87 | result.output.first 88 | ) 89 | } 90 | 91 | func testImplicitMatchClashes() async { 92 | await assertNotMatchClash(MatchDescriptorChain(), MatchDescriptorChain(all: P.a)) 93 | await assertNotMatchClash(MatchDescriptorChain(), MatchDescriptorChain(all: P.a, Q.a)) 94 | await assertNotMatchClash(MatchDescriptorChain(all: P.a), MatchDescriptorChain(all: Q.a, S.a)) 95 | 96 | await assertNotMatchClash(MatchDescriptorChain(all: P.a), MatchDescriptorChain(all: P.b)) 97 | await assertNotMatchClash(MatchDescriptorChain(all: P.a), MatchDescriptorChain(all: P.b, Q.b)) 98 | await assertNotMatchClash(MatchDescriptorChain(all: P.a, Q.a), MatchDescriptorChain(all: P.b, Q.b)) 99 | 100 | assertMatchClash(MatchDescriptorChain(all: P.a), MatchDescriptorChain(all: Q.a)) 101 | assertMatchClash(MatchDescriptorChain(all: P.a), MatchDescriptorChain(any: Q.a)) 102 | assertMatchClash(MatchDescriptorChain(all: P.a, R.a), MatchDescriptorChain(all: Q.a, S.a)) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Matching/PredicateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | typealias Predicate = SwiftFSM.Predicate 5 | 6 | private protocol NeverEqual { }; extension NeverEqual { 7 | static func == (lhs: Self, rhs: Self) -> Bool { false } 8 | } 9 | 10 | private protocol AlwaysEqual { }; extension AlwaysEqual { 11 | static func == (lhs: Self, rhs: Self) -> Bool { true } 12 | } 13 | 14 | final class PredicateTests: XCTestCase { 15 | enum NeverEqualPredicate: Predicate, NeverEqual { case a } 16 | enum AlwaysEqualPredicate: Predicate, AlwaysEqual { case a } 17 | 18 | func testDescription() { 19 | XCTAssertEqual(NeverEqualPredicate.a.erased().description, 20 | "NeverEqualPredicate.a") 21 | } 22 | 23 | func testPredicateInequality() { 24 | let p1 = NeverEqualPredicate.a.erased() 25 | let p2 = NeverEqualPredicate.a.erased() 26 | 27 | XCTAssertNotEqual(p1, p2) 28 | } 29 | 30 | func testPredicateEquality() { 31 | let p1 = AlwaysEqualPredicate.a.erased() 32 | let p2 = AlwaysEqualPredicate.a.erased() 33 | 34 | XCTAssertEqual(p1, p2) 35 | } 36 | 37 | func testPredicateFalseSet() { 38 | let p1 = NeverEqualPredicate.a.erased() 39 | let p2 = NeverEqualPredicate.a.erased() 40 | 41 | XCTAssertEqual(2, Set([p1, p2]).count) 42 | } 43 | 44 | func testPredicateTrueSet() { 45 | let p1 = AlwaysEqualPredicate.a.erased() 46 | let p2 = AlwaysEqualPredicate.a.erased() 47 | 48 | XCTAssertEqual(1, Set([p1, p2]).count) 49 | } 50 | 51 | func testPredicateDictionaryLookup() { 52 | let p1 = AlwaysEqualPredicate.a.erased() 53 | let p2 = NeverEqualPredicate.a.erased() 54 | 55 | let a = [p1: "Pass"] 56 | let b = [p2: "Pass"] 57 | 58 | XCTAssertEqual(a[p1], "Pass") 59 | XCTAssertNil(a[p2]) 60 | 61 | XCTAssertNil(b[p1]) 62 | XCTAssertNil(b[p2]) 63 | } 64 | 65 | @MainActor 66 | func testErasedWrapperUsesWrappedHasher() { 67 | struct Spy: Predicate, NeverEqual { 68 | let fulfill: @Sendable () -> () 69 | static var allCases: [Spy] { [] } 70 | func hash(into hasher: inout Hasher) { fulfill() } 71 | } 72 | 73 | let e = expectation(description: "hash") 74 | let anyPredicate = Spy(fulfill: e.fulfill).erased() 75 | let _ = [anyPredicate: "Pass"] 76 | waitForExpectations(timeout: 0.1) 77 | } 78 | 79 | func testBasePreservesType() { 80 | let a1 = P.a.erased().unwrap(to: P.self) 81 | let a2 = P.a 82 | 83 | XCTAssertEqual(a1, a2) 84 | } 85 | 86 | func testAllCases() { 87 | XCTAssertEqual(P.a.allCases.erased(), P.allCases.erased()) 88 | XCTAssertEqual(P.a.erased().allCases, P.allCases.erased()) 89 | } 90 | } 91 | 92 | final class PredicateCombinationsTests: XCTestCase { 93 | func testCombinationsAccuracy() { 94 | enum P: Predicate { case a, b } 95 | enum Q: Predicate { case a, b } 96 | enum R: Predicate { case a, b } 97 | 98 | let predicates = [Q.a, Q.b, P.a, P.b, R.b, R.b].erased() 99 | 100 | let expected = [[P.a, Q.a, R.a], 101 | [P.b, Q.a, R.a], 102 | [P.a, Q.a, R.b], 103 | [P.b, Q.a, R.b], 104 | [P.a, Q.b, R.a], 105 | [P.b, Q.b, R.a], 106 | [P.a, Q.b, R.b], 107 | [P.b, Q.b, R.b]].erasedSets 108 | 109 | XCTAssertEqual(expected, predicates.combinationsOfAllCases) 110 | } 111 | 112 | func testLargeCombinations() { 113 | enum P: Predicate { case a, b, c, d, e, f, g, h, i, j, k, l, m, n } // 10 114 | enum Q: Predicate { case a, b, c, d, e, f, g, h, i, j, k, l, m, n } // 10 115 | enum R: Predicate { case a, b, c, d, e, f, g, h, i, j, k, l, m, n } // 10 116 | 117 | let predicates = [Q.a, Q.b, P.a, P.b, R.b, R.b].erased() 118 | 119 | XCTAssertEqual(P.allCases.count * Q.allCases.count * R.allCases.count, // 1000, O(m^n) 120 | predicates.combinationsOfAllCases.count) 121 | } 122 | } 123 | 124 | 125 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/OperatorSyntax/PipeOperators.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Syntax.MatchingWhen { 4 | static func | ( 5 | lhs: Syntax.MatchingWhen, 6 | rhs: Syntax.Then 7 | ) -> Syntax.MatchingWhenThen { 8 | .init(node: rhs.node.appending(lhs.node)) 9 | } 10 | 11 | static func | ( 12 | lhs: Syntax.MatchingWhen, 13 | rhs: @escaping FSMAction 14 | ) -> Syntax.MatchingWhenActions { 15 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 16 | } 17 | 18 | static func | ( 19 | lhs: Syntax.MatchingWhen, 20 | rhs: @escaping FSMActionWithEvent 21 | ) -> Syntax.MatchingWhenActions { 22 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 23 | } 24 | 25 | static func | ( 26 | lhs: Syntax.MatchingWhen, 27 | rhs: [AnyAction] 28 | ) -> Syntax.MatchingWhenActions { 29 | .init(node: ActionsNode(actions: rhs, rest: [lhs.node])) 30 | } 31 | 32 | static func | ( 33 | lhs: Syntax.MatchingWhen, 34 | rhs: Syntax.Then 35 | ) -> Syntax.MatchingWhenThenActions { 36 | .init(node: ActionsNode(rest: [rhs.node.appending(lhs.node)])) 37 | } 38 | } 39 | 40 | public extension Syntax.MatchingThen { 41 | static func | ( 42 | lhs: Syntax.MatchingThen, 43 | rhs: @escaping FSMAction 44 | ) -> Syntax.MatchingThenActions { 45 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 46 | } 47 | 48 | static func | ( 49 | lhs: Syntax.MatchingThen, 50 | rhs: @escaping FSMActionWithEvent 51 | ) -> Syntax.MatchingThenActions { 52 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 53 | } 54 | 55 | static func | ( 56 | lhs: Syntax.MatchingThen, 57 | rhs: [AnyAction] 58 | ) -> Syntax.MatchingThenActions { 59 | .init(node: ActionsNode(actions: rhs, rest: [lhs.node])) 60 | } 61 | } 62 | 63 | public extension Syntax.MatchingWhenThen { 64 | static func | ( 65 | lhs: Syntax.MatchingWhenThen, 66 | rhs: @escaping FSMAction 67 | ) -> Syntax.MatchingWhenThenActions { 68 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 69 | } 70 | 71 | static func | ( 72 | lhs: Syntax.MatchingWhenThen, 73 | rhs: @escaping FSMActionWithEvent 74 | ) -> Syntax.MatchingWhenThenActions { 75 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 76 | } 77 | 78 | static func | ( 79 | lhs: Syntax.MatchingWhenThen, 80 | rhs: [AnyAction] 81 | ) -> Syntax.MatchingWhenThenActions { 82 | .init(node: ActionsNode(actions: rhs, rest: [lhs.node])) 83 | } 84 | } 85 | 86 | public extension Syntax.Conditional { 87 | static func | ( 88 | lhs: Self, 89 | rhs: Syntax.When 90 | ) -> Syntax.MatchingWhen { 91 | .init(node: rhs.node.appending(lhs.node)) 92 | } 93 | 94 | static func | ( 95 | lhs: Self, 96 | rhs: Syntax.When 97 | ) -> Syntax.MatchingWhenActions { 98 | .init(node: ActionsNode(rest: [rhs.node.appending(lhs.node)])) 99 | } 100 | 101 | static func | ( 102 | lhs: Self, 103 | rhs: Syntax.Then 104 | ) -> Syntax.MatchingThen { 105 | .init(node: rhs.node.appending(lhs.node)) 106 | } 107 | 108 | static func | ( 109 | lhs: Self, 110 | rhs: Syntax.Then 111 | ) -> Syntax.MatchingThenActions { 112 | .init(node: ActionsNode(rest: [rhs.node.appending(lhs.node)])) 113 | } 114 | 115 | static func | ( 116 | lhs: Self, 117 | rhs: @escaping FSMAction 118 | ) -> Syntax.MatchingActions { 119 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 120 | } 121 | 122 | static func | ( 123 | lhs: Self, 124 | rhs: @escaping FSMActionWithEvent 125 | ) -> Syntax.MatchingActions { 126 | .init(node: ActionsNode(actions: [AnyAction(rhs)], rest: [lhs.node])) 127 | } 128 | 129 | static func | ( 130 | lhs: Self, 131 | rhs: [AnyAction] 132 | ) -> Syntax.MatchingActions { 133 | .init(node: ActionsNode(actions: rhs, rest: [lhs.node])) 134 | } 135 | 136 | } 137 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/PublicAPITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftFSM 3 | // Do not use @testable here // 4 | 5 | final class PublicAPITests: XCTestCase { 6 | // These make little attempt to avoid duplication, as the point is to test the public API as-is, so polymorphism, additional protocols, etc. should be avoided 7 | 8 | class SUT: SyntaxBuilder { 9 | enum State { case locked, unlocked } 10 | enum Event { case coin, pass } 11 | 12 | let turnstile: FSM 13 | 14 | init() throws { 15 | turnstile = FSM(initialState: .locked) 16 | 17 | try turnstile.buildTable { 18 | define(.locked) { 19 | when(.coin) | then(.unlocked) | unlock 20 | when(.pass) | then(.locked) | alarm 21 | } 22 | 23 | define(.unlocked) { 24 | when(.coin) | then(.unlocked) | thankyou 25 | when(.pass) | then(.locked) | lock 26 | } 27 | } 28 | } 29 | 30 | func unlock() async { logAction() } 31 | func alarm() async { logAction() } 32 | func thankyou() async { logAction() } 33 | func lock() async { logAction() } 34 | 35 | var log = [String]() 36 | 37 | func logAction(_ f: String = #function) { 38 | log.append(f) 39 | } 40 | } 41 | 42 | func testPublicAPI() async throws { 43 | func assertLogged(_ a: String..., line: UInt = #line) { 44 | XCTAssertEqual(sut.log, a, line: line) 45 | } 46 | 47 | let sut = try SUT() 48 | XCTAssert(sut.log.isEmpty) 49 | 50 | await sut.turnstile.handleEvent(.coin) 51 | assertLogged("unlock()") 52 | 53 | await sut.turnstile.handleEvent(.coin) 54 | assertLogged("unlock()", "thankyou()") 55 | 56 | await sut.turnstile.handleEvent(.coin) 57 | assertLogged("unlock()", "thankyou()", "thankyou()") 58 | 59 | await sut.turnstile.handleEvent(.pass) 60 | assertLogged("unlock()", "thankyou()", "thankyou()", "lock()") 61 | 62 | await sut.turnstile.handleEvent(.pass) 63 | assertLogged("unlock()", "thankyou()", "thankyou()", "lock()", "alarm()") 64 | } 65 | 66 | @MainActor 67 | class MainActorSUT: SyntaxBuilder { 68 | enum State { case locked, unlocked } 69 | enum Event { case coin, pass } 70 | 71 | let turnstile: FSM.OnMainActor 72 | 73 | init() throws { 74 | turnstile = FSM.OnMainActor(initialState: .locked) 75 | 76 | try turnstile.buildTable { 77 | define(.locked) { 78 | when(.coin) | then(.unlocked) | unlock 79 | when(.pass) | then(.locked) | alarm 80 | } 81 | 82 | define(.unlocked) { 83 | when(.coin) | then(.unlocked) | thankyou 84 | when(.pass) | then(.locked) | lock 85 | } 86 | } 87 | } 88 | 89 | func unlock() async { logAction() } 90 | func alarm() async { logAction() } 91 | func thankyou() async { logAction() } 92 | func lock() async { logAction() } 93 | 94 | var log = [String]() 95 | 96 | func logAction(_ f: String = #function) { 97 | log.append(f) 98 | } 99 | } 100 | 101 | @MainActor 102 | func testMainActorPublicAPI() async throws { 103 | func assertLogged(_ a: String..., line: UInt = #line) { 104 | XCTAssertEqual(sut.log, a, line: line) 105 | } 106 | 107 | let sut = try MainActorSUT() 108 | XCTAssert(sut.log.isEmpty) 109 | 110 | await sut.turnstile.handleEvent(.coin) 111 | assertLogged("unlock()") 112 | 113 | await sut.turnstile.handleEvent(.coin) 114 | assertLogged("unlock()", "thankyou()") 115 | 116 | await sut.turnstile.handleEvent(.coin) 117 | assertLogged("unlock()", "thankyou()", "thankyou()") 118 | 119 | await sut.turnstile.handleEvent(.pass) 120 | assertLogged("unlock()", "thankyou()", "thankyou()", "lock()") 121 | 122 | await sut.turnstile.handleEvent(.pass) 123 | assertLogged("unlock()", "thankyou()", "thankyou()", "lock()", "alarm()") 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FSM.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum StateActionsPolicy { 4 | case executeAlways, executeOnChangeOnly 5 | } 6 | 7 | public class FSM { 8 | public enum PredicateHandling { case eager, lazy } 9 | 10 | typealias Precondition = ( 11 | @autoclosure () -> Bool, 12 | @autoclosure () -> String, 13 | StaticString, 14 | UInt 15 | ) -> () 16 | 17 | var assertsIsolation: Bool 18 | var isolation: (any Actor) = NonIsolated() 19 | var isolationWasSet = false 20 | var _precondition: Precondition = Swift.precondition 21 | 22 | var fsm: Base 23 | 24 | public init( 25 | type: PredicateHandling = .eager, 26 | initialState initial: State, 27 | actionsPolicy policy: StateActionsPolicy = .executeOnChangeOnly, 28 | enforceConcurrency: Bool = false 29 | ) { 30 | fsm = switch type { 31 | case .eager: Eager(initialState: initial, actionsPolicy: policy) 32 | case .lazy: Lazy(initialState: initial, actionsPolicy: policy) 33 | } 34 | 35 | self.assertsIsolation = enforceConcurrency 36 | } 37 | 38 | public func buildTable( 39 | file: StaticString = #file, 40 | line: Int = #line, 41 | isolation: isolated (any Actor)? = #isolation, 42 | @TableBuilder _ block: @isolated(any) () -> [Syntax.Define] 43 | ) throws { 44 | verifyIsolation(isolation, file: file, line: UInt(line)) 45 | try fsm.buildTable(file: "\(file)", line: line, isolation: isolation, block) 46 | } 47 | 48 | public func handleEvent( 49 | _ event: Event, 50 | predicates: (any Predicate)..., 51 | isolation: isolated (any Actor)? = #isolation, 52 | file: StaticString = #file, 53 | line: UInt = #line 54 | ) async { 55 | verifyIsolation(isolation, file: file, line: line) 56 | await handleEvent(event, predicates: predicates, isolation: isolation) 57 | } 58 | 59 | internal func handleEvent( 60 | _ event: Event, 61 | predicates: [any Predicate], 62 | isolation: isolated (any Actor)? = #isolation, 63 | file: StaticString = #file, 64 | line: UInt = #line 65 | ) async { 66 | await fsm.handleEvent(event, predicates: predicates, isolation: isolation) 67 | } 68 | } 69 | 70 | extension FSM { 71 | private actor NonIsolated { } 72 | 73 | internal func verifyIsolation( 74 | _ isolation: (any Actor)?, 75 | caller: StaticString = #function, 76 | file: StaticString, 77 | line: UInt 78 | ) { 79 | #if DEBUG 80 | guard assertsIsolation else { return } 81 | 82 | let isolation = isolation ?? NonIsolated() 83 | 84 | if isolationWasSet { 85 | assertIsolation(isolation, caller: caller, file: file, line: line) 86 | } else { 87 | setIsolation(isolation) 88 | } 89 | #endif 90 | } 91 | 92 | private func assertIsolation( 93 | _ isolation: (any Actor), 94 | caller: StaticString, 95 | file: StaticString, 96 | line: UInt 97 | ) { 98 | let current = type(of: isolation) 99 | let previous = type(of: self.isolation) 100 | let message = "Concurrency violation: \(caller) called by \(current) (expected \(previous))" 101 | 102 | _precondition(current == previous, message, file, UInt(line)) 103 | } 104 | 105 | private func setIsolation(_ isolation: (any Actor)) { 106 | self.isolation = isolation 107 | isolationWasSet = true 108 | } 109 | } 110 | 111 | extension FSM { 112 | @MainActor 113 | public class OnMainActor { 114 | var fsm: FSM 115 | 116 | public init( 117 | type: FSM.PredicateHandling = .eager, 118 | initialState initial: State, 119 | actionsPolicy policy: StateActionsPolicy = .executeOnChangeOnly 120 | ) { 121 | fsm = FSM( 122 | type: type, 123 | initialState: initial, 124 | actionsPolicy: policy 125 | ) 126 | } 127 | 128 | public func buildTable( 129 | file: StaticString = #file, 130 | line: Int = #line, 131 | @TableBuilder _ block: @MainActor () -> [Syntax.Define] 132 | ) throws { 133 | try fsm.buildTable(file: file, line: line, block) 134 | } 135 | 136 | public func handleEvent( 137 | _ event: Event, 138 | predicates: any Predicate... 139 | ) async { 140 | await fsm.handleEvent(event, predicates: predicates) 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/DefineTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class DefineTests: BlockTestsBase { 5 | func testDefine() async { 6 | func verify( 7 | _ d: Define, 8 | hasEvent: Bool = false, 9 | sutLine sl: Int = #line, 10 | elementLine el: Int = mwtaLine, 11 | xctLine xl: UInt = #line 12 | ) async { 13 | assertNeverEmptyNode(d.node, caller: "define", sutLine: sl, xctLine: xl) 14 | 15 | XCTAssertEqual(1, d.node.rest.count, line: xl) 16 | let gNode = d.node.rest.first as! GivenNode 17 | XCTAssertEqual([1], gNode.states.map(\.base)) 18 | 19 | await assertMWTAResult(gNode.rest, sutLine: el, xctLine: xl) 20 | await assertActions(d.node.onEntry + d.node.onExit, 21 | expectedOutput: "entry1exit1", 22 | xctLine: xl) 23 | } 24 | 25 | func assertEmpty(_ d: Define, xctLine: UInt = #line) { 26 | XCTAssertEqual(0, d.node.rest.count, line: xctLine) 27 | } 28 | 29 | let s = SuperState { mwtaBlock } 30 | 31 | await verify(define(1, adopts: s, onEntry: entry1, onExit: exit1)) 32 | await verify(define(1, onEntry: entry1, onExit: exit1) { mwtaBlock }) 33 | 34 | assertEmpty(define(1, onEntry: entry1, onExit: exit1) { }) 35 | 36 | // technically valid/non-empty but need to flag empty trailing block 37 | assertEmpty(define(1, adopts: s, onEntry: entry1, onExit: exit1) { }) 38 | } 39 | 40 | func testDefineAddsSuperStateEntryExitActions() async { 41 | let s1 = SuperState(onEntry: entry1, onExit: exit1) { 42 | matching(P.a) | when(1, or: 2) | then(1) | pass 43 | when(1, or: 2) | then(1) | pass 44 | } 45 | 46 | let d1 = define(1, adopts: s1, s1, onEntry: entry2, onExit: exit2) 47 | 48 | await assertActions(d1.node.onEntry, expectedOutput: "entry1entry1entry2") 49 | await assertActions(d1.node.onExit, expectedOutput: "exit1exit1exit2") 50 | } 51 | 52 | func testDefineAddsMultipleSuperStateNodes() async { 53 | let l1 = #line + 1; let s1 = SuperState(onEntry: entry1, onExit: exit1) { 54 | matching(P.a) | when(1, or: 2) | then(1) | pass 55 | when(1, or: 2) | then(1) | pass 56 | } 57 | 58 | let g1 = define(1, adopts: s1, s1, onEntry: entry1, onExit: exit1) 59 | .node 60 | .rest[0] as! GivenNode 61 | 62 | await assertMWTAResult(Array(g1.rest.prefix(2)), sutFile: #file, sutLine: l1) 63 | await assertMWTAResult(Array(g1.rest.suffix(2)), sutFile: #file, sutLine: l1) 64 | } 65 | 66 | func testDefineAddsBlockAndSuperStateNodesTogetherParentFirst() async { 67 | func assertDefine(_ n: DefineNode, line: UInt = #line) async { 68 | func castRest(_ n: [U], to: T.Type) -> [T] { 69 | n.map { $0.rest }.flattened as! [T] 70 | } 71 | 72 | let givens = n.rest as! [GivenNode] 73 | let actions = castRest(givens, to: ActionsNode.self) 74 | let thens = castRest(actions, to: ThenNode.self) 75 | let whens = castRest(thens, to: WhenNode.self) 76 | 77 | func givenStates(_ n: GivenNode?) -> [AnyHashable] { bases(n?.states) } 78 | func events(_ n: WhenNode?) -> [AnyHashable] { bases(n?.events) } 79 | func thenState(_ n: ThenNode?) -> AnyHashable { n?.state?.base } 80 | func bases(_ t: [AnyTraceable]?) -> [AnyHashable] { t?.map(\.base) ?? [] } 81 | 82 | XCTAssertEqual([1], givenStates(givens(0)), line: line) 83 | XCTAssertEqual([[1], [2]], [events(whens(0)), events(whens(1))], line: line) 84 | XCTAssertEqual([1, 2], [thenState(thens(0)), thenState(thens(1))], line: line) 85 | 86 | await assertActions(actions.map(\.actions).flattened, 87 | expectedOutput: "passpass", 88 | xctLine: line) 89 | } 90 | 91 | let s = SuperState { when(1) | then(1) | pass } 92 | let d1 = define(1, adopts: s) { when(2) | then(2) | pass } 93 | await assertDefine(d1.node) 94 | } 95 | 96 | func testDefineSetsUniqueGroupIDForOwnNodesOnly() { 97 | let s = SuperState { 98 | when(1) | then(1) | pass 99 | } 100 | 101 | let d = define(1, adopts: s) { 102 | when(2) | then(2) | pass 103 | when(3) | then(3) | pass 104 | } 105 | 106 | let given = d.node.rest.first as! GivenNode 107 | assertGroupID(given.rest) 108 | } 109 | 110 | func testOptionalActions() async { 111 | let l1 = #line; let d = define(1) { 112 | matching(P.a) | when(1, or: 2) | then(1) 113 | when(1, or: 2) | then(1) 114 | } 115 | 116 | await assertMWTAResult(d.node.rest.nodes, expectedOutput: "", sutFile: #file, sutLine: l1 + 1) 117 | } 118 | } 119 | 120 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/Validation/MatchResolvingNode/EagerMRN.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | final class EagerMatchResolvingNode: MatchResolvingNode { 4 | struct ErrorOutput { 5 | let state: AnyTraceable, 6 | descriptor: MatchDescriptorChain, 7 | event: AnyTraceable, 8 | nextState: AnyTraceable 9 | 10 | init( 11 | _ state: AnyTraceable, 12 | _ match: MatchDescriptorChain, 13 | _ event: AnyTraceable, 14 | _ nextState: AnyTraceable 15 | ) { 16 | self.state = state 17 | self.descriptor = match 18 | self.event = event 19 | self.nextState = nextState 20 | } 21 | } 22 | 23 | struct RankedOutput { 24 | let state: AnyTraceable, 25 | descriptor: MatchDescriptorChain, 26 | predicateResult: RankedPredicates, 27 | event: AnyTraceable, 28 | nextState: AnyTraceable, 29 | actions: [AnyAction] 30 | 31 | var toTransition: Transition { 32 | Transition(descriptor.condition, 33 | state.base, 34 | predicateResult.predicates, 35 | event.base, 36 | nextState.base, 37 | actions) 38 | } 39 | 40 | var toErrorOutput: ErrorOutput { 41 | ErrorOutput(state, descriptor, event, nextState) 42 | } 43 | } 44 | 45 | struct ImplicitClashesError: Error { 46 | let clashes: ImplicitClashesDictionary 47 | } 48 | 49 | struct ImplicitClashesKey: FSMHashable { 50 | let state: AnyTraceable, 51 | predicates: PredicateSet, 52 | event: AnyTraceable 53 | 54 | init(_ state: AnyTraceable, _ predicates: PredicateSet, _ event: AnyTraceable) { 55 | self.state = state 56 | self.predicates = predicates 57 | self.event = event 58 | } 59 | 60 | init(_ output: RankedOutput) { 61 | self.state = output.state 62 | self.predicates = output.predicateResult.predicates 63 | self.event = output.event 64 | } 65 | } 66 | 67 | typealias ImplicitClashesDictionary = [ImplicitClashesKey: [ErrorOutput]] 68 | 69 | var rest: [any SyntaxNode] 70 | var errors: [Error] = [] 71 | 72 | required init(rest: [any SyntaxNode] = []) { 73 | self.rest = rest 74 | } 75 | 76 | func combinedWith(_ rest: [SemanticValidationNode.Output]) -> [Transition] { 77 | var clashes = ImplicitClashesDictionary() 78 | let allCases = rest.allCases() 79 | 80 | let result = rest.reduce(into: [RankedOutput]()) { result, input in 81 | func appendInput(_ predicateResult: RankedPredicates = RankedPredicates.empty) { 82 | let ro = RankedOutput(state: input.state, 83 | descriptor: input.descriptor, 84 | predicateResult: predicateResult, 85 | event: input.event, 86 | nextState: input.nextState, 87 | actions: input.actions) 88 | 89 | func isRankedClash(_ lhs: RankedOutput) -> Bool { 90 | isClash(lhs) && lhs.predicateResult.rank != ro.predicateResult.rank 91 | } 92 | 93 | func isClash(_ lhs: RankedOutput) -> Bool { 94 | ImplicitClashesKey(lhs) == ImplicitClashesKey(ro) 95 | } 96 | 97 | func highestRank(_ lhs: RankedOutput, _ rhs: RankedOutput) -> RankedOutput { 98 | lhs.predicateResult.rank > rhs.predicateResult.rank ? lhs : rhs 99 | } 100 | 101 | if let i = result.firstIndex(where: isRankedClash) { 102 | result[i] = highestRank(result[i], ro) 103 | } else { 104 | if let clash = result.first(where: isClash) { 105 | let key = ImplicitClashesKey(ro) 106 | clashes[key] = (clashes[key] ?? [clash.toErrorOutput]) + [ro.toErrorOutput] 107 | } 108 | result.append(ro) 109 | } 110 | } 111 | 112 | let allPredicateCombinations = input.descriptor.allPredicateCombinations(allCases) 113 | guard !allPredicateCombinations.isEmpty else { 114 | appendInput(); return 115 | } 116 | 117 | allPredicateCombinations.forEach(appendInput) 118 | } 119 | 120 | if !clashes.isEmpty { 121 | errors.append(ImplicitClashesError(clashes: clashes)) 122 | } 123 | 124 | return result.map(\.toTransition) 125 | } 126 | } 127 | 128 | extension RankedPredicates { 129 | static var empty: Self { 130 | Self([], priority: 0) 131 | } 132 | } 133 | 134 | extension [SemanticValidationNode.Output] { 135 | func allCases() -> PredicateSets { 136 | let descriptors = map(\.descriptor) 137 | let anys = descriptors.map(\.matchingAny) 138 | let alls = descriptors.map(\.matchingAll) 139 | return (alls + anys.flattened).flattened.combinationsOfAllCases 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/DefineNodeTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class DefineNodeTests: SyntaxNodeTests { 5 | func testEmptyDefineNodeProducesError() { 6 | assertEmptyNodeWithError( 7 | DefineNode( 8 | onEntry: [], 9 | onExit: [], 10 | rest: [], 11 | caller: "caller", 12 | file: "file", 13 | line: 10 14 | ) 15 | ) 16 | } 17 | 18 | func testDefineNodeWithActionsButNoRestProducesError() { 19 | assertEmptyNodeWithError( 20 | DefineNode( 21 | onEntry: [AnyAction({ })], 22 | onExit: [AnyAction({ })], 23 | rest: [], 24 | caller: "caller", 25 | file: "file", 26 | line: 10 27 | ) 28 | ) 29 | } 30 | 31 | func testCompleteNodeWithInvalidMatchProducesErrorAndNoOutput() { 32 | let invalidMatch = MatchDescriptorChain(all: P.a, P.a) 33 | 34 | let m = MatchingNode(descriptor: invalidMatch, rest: [WhenNode(events: [e1])]) 35 | let g = GivenNode(states: [s1], rest: [m]) 36 | let d = DefineNode(onEntry: [], onExit: [], rest: [g]) 37 | 38 | let result = d.resolve() 39 | 40 | XCTAssertEqual(0, result.output.count) 41 | XCTAssertEqual(1, result.errors.count) 42 | XCTAssertTrue(result.errors.first is MatchError) 43 | } 44 | 45 | func testDefineNodeWithNoActions() async { 46 | let d = DefineNode(onEntry: [], 47 | onExit: [], 48 | rest: [givenNode(thenState: s3, 49 | actionsNode: ActionsNode(actions: []))]) 50 | 51 | let expected = [MSES(m1, s1, e1, s3), 52 | MSES(m1, s1, e2, s3), 53 | MSES(m1, s2, e1, s3), 54 | MSES(m1, s2, e2, s3)] 55 | 56 | await assertDefineNode(expected: expected, 57 | actionsOutput: "", 58 | node: d) 59 | } 60 | 61 | func testDefineNodeCanSetRestAfterInit() async { 62 | let t = ThenNode(state: s3, rest: []) 63 | let w = WhenNode(events: [e1, e2], rest: [t]) 64 | let m = MatchingNode(descriptor: m1, rest: [w]) 65 | let g = GivenNode(states: [s1, s2], rest: [m]) 66 | 67 | let d = DefineNode(onEntry: [], 68 | onExit: []) 69 | d.rest.append(g) 70 | 71 | let expected = [MSES(m1, s1, e1, s3), 72 | MSES(m1, s1, e2, s3), 73 | MSES(m1, s2, e1, s3), 74 | MSES(m1, s2, e2, s3)] 75 | 76 | await assertDefineNode( 77 | expected: expected, 78 | actionsOutput: "", 79 | node: d 80 | ) 81 | } 82 | 83 | func testDefineNodeWithMultipleGivensWithEntryActionsAndExitActions() async { 84 | let d = DefineNode(onEntry: onEntry, 85 | onExit: onExit, 86 | rest: [givenNode(thenState: s3, 87 | actionsNode: actionsNode), 88 | givenNode(thenState: s3, 89 | actionsNode: actionsNode)]) 90 | 91 | let expected = [MSES(m1, s1, e1, s3), 92 | MSES(m1, s1, e2, s3), 93 | MSES(m1, s2, e1, s3), 94 | MSES(m1, s2, e2, s3), 95 | MSES(m1, s1, e1, s3), 96 | MSES(m1, s1, e2, s3), 97 | MSES(m1, s2, e1, s3), 98 | MSES(m1, s2, e2, s3)] 99 | 100 | await assertDefineNode( 101 | expected: expected, 102 | actionsOutput: "<<12>><<12>><<12>><<12>><<12>><<12>><<12>><<12>>", 103 | node: d 104 | ) 105 | } 106 | 107 | func testDefineNodeDoesNotAddEntryAndExitActionsIfStateDoesNotChange() async { 108 | let d = DefineNode(onEntry: onEntry, 109 | onExit: onExit, 110 | rest: [givenNode(thenState: nil, 111 | actionsNode: actionsNode)]) 112 | 113 | let expected = [MSES(m1, s1, e1, s1), 114 | MSES(m1, s1, e2, s1), 115 | MSES(m1, s2, e1, s2), 116 | MSES(m1, s2, e2, s2)] 117 | 118 | await assertDefineNode(expected: expected, 119 | actionsOutput: "", 120 | node: d) 121 | } 122 | 123 | func testDefineNodePassesGroupIDAndIsOverrideParams() { 124 | let t = ThenNode(state: s3, rest: [actionsNode]) 125 | let w = WhenNode(events: [e1], rest: [t]) 126 | let m = MatchingNode(descriptor: m1, rest: [w], overrideGroupID: testGroupID, isOverride: true) 127 | let g = GivenNode(states: [s1], rest: [m]) 128 | let output = DefineNode(onEntry: [], onExit: [], rest: [g]).resolve().output 129 | 130 | XCTAssert(output.allSatisfy { $0.overrideGroupID == testGroupID && $0.isOverride == true }) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundSyntaxTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | final class CompoundSyntaxTests: SyntaxTestsBase { 5 | func assertMW( 6 | _ mw: MatchingWhen, 7 | sutLine sl: Int, 8 | xctLine xl: UInt = #line 9 | ) async { 10 | await assertMWNode(mw.node, sutLine: sl, xctLine: xl) 11 | } 12 | 13 | func assertMWNode(_ n: N, sutLine sl: Int, xctLine xl: UInt = #line) async{ 14 | let whenNode = n as! WhenNode 15 | let matchNode = n.rest.first as! MatchingNode 16 | 17 | XCTAssertEqual(1, whenNode.rest.count, line: xl) 18 | XCTAssertEqual(0, matchNode.rest.count, line: xl) 19 | 20 | assertWhenNode(whenNode, sutLine: sl, xctLine: xl) 21 | await assertMatchNode(matchNode, all: [P.a], sutLine: sl, xctLine: xl) 22 | } 23 | 24 | func testMatching() async { 25 | await assertMatching(matching(P.a), all: P.a) 26 | await assertMatching(matching(P.a, or: P.b, line: -1), any: P.a, P.b, sutLine: -1) 27 | await assertMatching(matching(P.a, and: Q.a, line: -1), all: P.a, Q.a, sutLine: -1) 28 | await assertMatching(matching(P.a, or: P.b, and: Q.a, R.a, line: -1), 29 | any: P.a, P.b, all: Q.a, R.a, sutLine: -1) 30 | } 31 | 32 | func testCondition() async { 33 | await assertCondition(condition({ true }), expected: true) 34 | } 35 | 36 | func testWhen() { 37 | assertWhen(when(1, or: 2)) 38 | assertWhen(when(1), events: [1]) 39 | } 40 | 41 | func testThen() { 42 | assertThen(then(1), sutFile: #file) 43 | assertThen(then(), state: nil, sutLine: nil) 44 | } 45 | 46 | func testMatchingWhen() async { 47 | await assertMW(matching(P.a) | when(1, or: 2), sutLine: #line) 48 | } 49 | 50 | func testMatchingWhenThen() async { 51 | func assertMWT( 52 | _ mwt: MatchingWhenThen, 53 | sutLine sl: Int, 54 | xctLine xl: UInt = #line 55 | ) async { 56 | let then = mwt.node 57 | let when = then.rest.first as! WhenNode 58 | 59 | XCTAssertEqual(1, then.rest.count, line: xl) 60 | assertThenNode(then as! ThenNodeBase, state: 1, sutFile: #file, sutLine: sl, xctLine: xl) 61 | await assertMWNode(when, sutLine: sl) 62 | } 63 | 64 | await assertMWT(matching(P.a) | when(1, or: 2) | then(1), sutLine: #line) 65 | } 66 | 67 | func testMatchingWhenThenActions() async { 68 | let mwta1 = matching(P.a) | when(1, or: 2) | then(1) | pass; let l1 = #line 69 | let mwta2 = matching(P.a) | when(1, or: 2) | then(1) | pass & pass; let l2 = #line 70 | 71 | await assertMWTA(mwta1.node, sutLine: l1) 72 | await assertMWTA(mwta2.node, 73 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 74 | sutLine: l2) 75 | } 76 | 77 | func testMatchingWhenThenActions_withEvent() async { 78 | let mwta = matching(P.a) | when(1, or: 2) | then(1) | passWithEvent; let l2 = #line 79 | await assertMWTA(mwta.node, event: 111, expectedOutput: "pass, event: 111", sutLine: l2) 80 | } 81 | 82 | func testMatchingWhenThenActionsAsync() async { 83 | let mwta = matching(P.a) | when(1, or: 2) | then(1) | passAsync; let l1 = #line 84 | await assertMWTA(mwta.node, sutLine: l1) 85 | } 86 | 87 | func testMatchingWhenThenActionsAsync_withEvent() async { 88 | let mwta = matching(P.a) | when(1, or: 2) | then(1) | passWithEventAsync; let l2 = #line 89 | await assertMWTA(mwta.node, event: 111, expectedOutput: "pass, event: 111", sutLine: l2) 90 | } 91 | 92 | func testWhenThen() { 93 | func assertWT(_ wt: MatchingWhenThen, sutLine sl: Int, xctLine xl: UInt = #line) { 94 | let then = wt.node 95 | let when = then.rest.first as! WhenNode 96 | 97 | XCTAssertEqual(1, then.rest.count, line: xl) 98 | XCTAssertEqual(0, when.rest.count, line: xl) 99 | 100 | assertThenNode(then as! ThenNodeBase, state: 1, sutFile: #file, sutLine: sl, xctLine: xl) 101 | assertWhenNode(when, sutLine: sl, xctLine: xl) 102 | } 103 | 104 | assertWT(when(1, or: 2) | then(1), sutLine: #line) 105 | } 106 | 107 | func testWhenThenActions() async { 108 | let wta1 = when(1, or: 2) | then(1) | pass; let l1 = #line 109 | let wta2 = when(1, or: 2) | then(1) | pass & pass; let l2 = #line 110 | 111 | await assertWTA(wta1.node, sutLine: l1) 112 | await assertWTA( 113 | wta2.node, 114 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 115 | sutLine: l2 116 | ) 117 | } 118 | 119 | func testWhenThenActionsAsync() async { 120 | let wta1 = when(1, or: 2) | then(1) | passAsync; let l1 = #line 121 | await assertWTA(wta1.node, sutLine: l1) 122 | } 123 | 124 | func testWhenThenActions_withEvent() async { 125 | let wta2 = when(1, or: 2) | then(1) | passWithEvent; let l2 = #line 126 | await assertWTA(wta2.node, expectedOutput: Self.defaultOutputWithEvent, sutLine: l2) 127 | } 128 | 129 | func testWhenThenActionsAsync_withEvent() async { 130 | let wta2 = when(1, or: 2) | then(1) | passWithEventAsync; let l2 = #line 131 | await assertWTA(wta2.node, expectedOutput: Self.defaultOutputWithEvent, sutLine: l2) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/FSM/FSMBase.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Swift bug: 4 | /// 5 | /// https://github.com/pointfreeco/swift-composable-architecture/issues/2666 6 | /// https://github.com/apple/swift/issues/69927 7 | /// 8 | /// The struct TableBuilder below should be internal, but when marked as such, Swift fails to link when compiling in release mode 9 | 10 | 11 | struct TableKey: @unchecked Sendable, Hashable { 12 | let state: AnyHashable 13 | let predicates: PredicateSet 14 | let event: AnyHashable 15 | 16 | init(state: AnyHashable, predicates: PredicateSet, event: AnyHashable) { 17 | self.state = state 18 | self.predicates = predicates 19 | self.event = event 20 | } 21 | 22 | init(_ value: Transition) { 23 | state = value.state 24 | predicates = value.predicates 25 | event = value.event 26 | } 27 | } 28 | 29 | extension FSM { 30 | @resultBuilder 31 | public struct TableBuilder: ResultBuilder { 32 | public typealias T = Syntax.Define 33 | } 34 | 35 | class Base { 36 | enum TransitionStatus { 37 | case executed(Transition), notFound(Event, [any Predicate]), notExecuted(Transition) 38 | } 39 | 40 | let stateActionsPolicy: StateActionsPolicy 41 | 42 | var table: [TableKey: Transition] = [:] 43 | var state: AnyHashable 44 | let logger = Logger() 45 | 46 | func makeMatchResolvingNode(rest: [any SyntaxNode]) -> any MatchResolvingNode { 47 | fatalError("subclasses must implement") 48 | } 49 | 50 | init(initialState: State, actionsPolicy: StateActionsPolicy = .executeOnChangeOnly) { 51 | self.state = initialState 52 | self.stateActionsPolicy = actionsPolicy 53 | } 54 | 55 | func buildTable( 56 | file: String = #file, 57 | line: Int = #line, 58 | isolation: isolated (any Actor)? = #isolation, 59 | @TableBuilder _ block: () -> [Syntax.Define] 60 | ) throws { 61 | guard table.isEmpty else { 62 | throw makeError(TableAlreadyBuiltError(file: file, line: line)) 63 | } 64 | 65 | let arn = makeActionsResolvingNode(rest: block().map(\.node)) 66 | let svn = SemanticValidationNode(rest: [arn]) 67 | let result = makeMatchResolvingNode(rest: [svn]).resolve() 68 | 69 | try checkForErrors(result) 70 | makeTable(result.output) 71 | } 72 | 73 | @discardableResult 74 | func handleEvent( 75 | _ event: Event, 76 | predicates: any Predicate..., 77 | isolation: isolated (any Actor)? = #isolation 78 | ) async -> TransitionStatus { 79 | await handleEvent(event, predicates: predicates, isolation: isolation) 80 | } 81 | 82 | @discardableResult 83 | func handleEvent( 84 | _ event: Event, 85 | predicates: [any Predicate], 86 | isolation: isolated (any Actor)? 87 | ) async -> TransitionStatus { 88 | guard let transition = transition(event, predicates) else { 89 | return .notFound(event, predicates) 90 | } 91 | 92 | guard await shouldExecute(transition, isolation: isolation) else { 93 | return .notExecuted(transition) 94 | } 95 | 96 | state = transition.nextState 97 | await transition.executeActions(event: event) 98 | return .executed(transition) 99 | } 100 | 101 | func transition(_ event: Event, _ predicates: [any Predicate]) -> Transition? { 102 | table[TableKey(state: state, 103 | predicates: Set(predicates.erased()), 104 | event: event)] 105 | } 106 | 107 | func shouldExecute( 108 | _ t: Transition, 109 | isolation: isolated (any Actor)? 110 | ) async -> Bool { 111 | t.condition?() ?? true 112 | } 113 | 114 | func makeActionsResolvingNode(rest: [DefineNode]) -> ActionsResolvingNode { 115 | switch stateActionsPolicy { 116 | case .executeAlways: ActionsResolvingNode.ExecuteAlways(rest: rest) 117 | case .executeOnChangeOnly: ActionsResolvingNode.OnStateChange(rest: rest) 118 | } 119 | } 120 | 121 | func checkForErrors(_ result: (output: [Transition], errors: [Error])) throws { 122 | if !result.errors.isEmpty { 123 | throw makeError(result.errors) 124 | } 125 | 126 | if result.output.isEmpty { 127 | throw makeError(EmptyTableError()) 128 | } 129 | } 130 | 131 | func makeTable(_ output: [Transition]) { 132 | output.forEach { table[TableKey($0)] = $0 } 133 | } 134 | 135 | func makeError(_ error: Error) -> SwiftFSMError { 136 | makeError([error]) 137 | } 138 | 139 | func makeError(_ errors: [Error]) -> SwiftFSMError { 140 | SwiftFSMError(errors: errors) 141 | } 142 | 143 | func logTransitionNotFound(_ event: Event, _ predicates: [any Predicate]) { 144 | logger.transitionNotFound(event, predicates) 145 | } 146 | 147 | func logTransitionNotExecuted(_ t: Transition) { 148 | logger.transitionNotExecuted(t) 149 | } 150 | 151 | func logTransitionExecuted(_ t: Transition) { 152 | logger.transitionExecuted(t) 153 | } 154 | } 155 | } 156 | 157 | private extension Transition { 158 | func executeActions(event: E) async { 159 | for action in actions { 160 | await action(event) 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Public/FunctionSyntax/ExpandedSyntaxBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ExpandedSyntaxBuilder: SyntaxBuilder { } 4 | 5 | // MARK: - Matching 6 | public extension ExpandedSyntaxBuilder { 7 | typealias Matching = Syntax.Matching 8 | 9 | func matching( 10 | _ predicate: P, 11 | file: String = #file, 12 | line: Int = #line 13 | ) -> Matching { 14 | .init(predicate, or: [], and: [], file: file, line: line) 15 | } 16 | 17 | func matching( 18 | _ predicate: P, 19 | or: P..., 20 | file: String = #file, 21 | line: Int = #line 22 | ) -> Matching { 23 | .init(predicate, or: or, and: [], file: file, line: line) 24 | } 25 | 26 | func matching( 27 | _ predicate: P, 28 | and: P..., 29 | file: String = #file, 30 | line: Int = #line 31 | ) -> Matching { 32 | .init(predicate, or: [], and: and, file: file, line: line) 33 | } 34 | 35 | func matching( 36 | _ predicate: P, 37 | or: P..., 38 | and: any Predicate..., 39 | file: String = #file, 40 | line: Int = #line 41 | ) -> Matching { 42 | .init(predicate, or: or, and: and, file: file, line: line) 43 | } 44 | 45 | func matching( 46 | _ predicate: P, 47 | file: String = #file, 48 | line: Int = #line, 49 | @Syntax.MWTABuilder _ group: () -> [Syntax.MatchingWhenThenActions] 50 | ) -> Syntax.MWTA_Group { 51 | Matching(predicate, or: [], and: [], file: file, line: line) 52 | .callAsFunction(group) 53 | } 54 | 55 | func matching( 56 | _ predicate: P, 57 | or: P..., 58 | file: String = #file, 59 | line: Int = #line, 60 | @Syntax.MWTABuilder _ group: () -> [Syntax.MatchingWhenThenActions] 61 | ) -> Syntax.MWTA_Group { 62 | Matching(predicate, or: or, and: [], file: file, line: line) 63 | .callAsFunction(group) 64 | } 65 | 66 | func matching( 67 | _ predicate: P, 68 | or: P..., 69 | and: any Predicate..., 70 | file: String = #file, 71 | line: Int = #line, 72 | @Syntax.MWTABuilder _ group: () -> [Syntax.MatchingWhenThenActions] 73 | ) -> Syntax.MWTA_Group { 74 | Matching(predicate, or: or, and: and, file: file, line: line) 75 | .callAsFunction(group) 76 | } 77 | 78 | func matching( 79 | _ predicate: P, 80 | file: String = #file, 81 | line: Int = #line, 82 | @Syntax.MWABuilder _ group: () -> [Syntax.MatchingWhenActions] 83 | ) -> Syntax.MWA_Group { 84 | Matching(predicate, or: [], and: [], file: file, line: line) 85 | .callAsFunction(group) 86 | } 87 | 88 | func matching( 89 | _ predicate: P, 90 | or: P..., 91 | file: String = #file, 92 | line: Int = #line, 93 | @Syntax.MWABuilder _ group: () -> [Syntax.MatchingWhenActions] 94 | ) -> Syntax.MWA_Group { 95 | Matching(predicate, or: or, and: [], file: file, line: line) 96 | .callAsFunction(group) 97 | } 98 | 99 | func matching( 100 | _ predicate: P, 101 | or: P..., 102 | and: any Predicate..., 103 | file: String = #file, 104 | line: Int = #line, 105 | @Syntax.MWABuilder _ group: () -> [Syntax.MatchingWhenActions] 106 | ) -> Syntax.MWA_Group { 107 | Matching(predicate, or: or, and: and, file: file, line: line) 108 | .callAsFunction(group) 109 | } 110 | 111 | func matching( 112 | _ predicate: P, 113 | file: String = #file, 114 | line: Int = #line, 115 | @Syntax.MTABuilder _ group: () -> [Syntax.MatchingThenActions] 116 | ) -> Syntax.MTA_Group { 117 | Matching(predicate, or: [], and: [], file: file, line: line) 118 | .callAsFunction(group) 119 | } 120 | 121 | func matching( 122 | _ predicate: P, 123 | or: P..., 124 | and: any Predicate..., 125 | file: String = #file, 126 | line: Int = #line, 127 | @Syntax.MTABuilder _ group: () -> [Syntax.MatchingThenActions] 128 | ) -> Syntax.MTA_Group { 129 | Matching(predicate, or: or, and: and, file: file, line: line) 130 | .callAsFunction(group) 131 | } 132 | } 133 | 134 | // MARK: - Condition 135 | public extension ExpandedSyntaxBuilder { 136 | typealias Condition = Syntax.Condition 137 | 138 | func condition( 139 | _ condition: @escaping ConditionProvider, 140 | file: String = #file, 141 | line: Int = #line, 142 | @Syntax.MWTABuilder _ group: () -> [Syntax.MatchingWhenThenActions] 143 | ) -> Syntax.MWTA_Group { 144 | Condition(condition, file: file, line: line) 145 | .callAsFunction(group) 146 | } 147 | 148 | func condition( 149 | _ condition: @escaping ConditionProvider, 150 | file: String = #file, 151 | line: Int = #line 152 | ) -> Condition { 153 | .init(condition, file: file, line: line) 154 | } 155 | 156 | func condition( 157 | _ condition: @escaping ConditionProvider, 158 | file: String = #file, 159 | line: Int = #line, 160 | @Syntax.MWABuilder _ group: () -> [Syntax.MatchingWhenActions] 161 | ) -> Syntax.MWA_Group { 162 | Condition(condition, file: file, line: line) 163 | .callAsFunction(group) 164 | } 165 | 166 | func condition( 167 | _ condition: @escaping ConditionProvider, 168 | file: String = #file, 169 | line: Int = #line, 170 | @Syntax.MTABuilder _ group: () -> [Syntax.MatchingThenActions] 171 | ) -> Syntax.MTA_Group { 172 | Condition(condition, file: file, line: line) 173 | .callAsFunction(group) 174 | } 175 | } 176 | 177 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/BlockTestsBase.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class BlockTestsBase: SyntaxTestsBase { 5 | typealias MWTABuilder = Syntax.MWTABuilder 6 | typealias MWABuilder = Syntax.MWABuilder 7 | typealias MTABuilder = Syntax.MTABuilder 8 | typealias MABuilder = Syntax.MABuilder 9 | typealias Actions = Syntax.Actions 10 | 11 | let baseFile = #file 12 | 13 | let mwtaLine = #line + 1; @MWTABuilder var mwtaBlock: [Syntax.MatchingWhenThenActions] { 14 | matching(P.a) | when(1, or: 2) | then(1) | pass 15 | when(1, or: 2) | then(1) | pass 16 | } 17 | 18 | let mwaLine = #line + 1; @MWABuilder var mwaBlock: [Syntax.MatchingWhenActions] { 19 | matching(P.a) | when(1, or: 2) | pass 20 | when(1, or: 2) | pass 21 | } 22 | 23 | let mtaLine = #line + 1; @MTABuilder var mtaBlock: [Syntax.MatchingThenActions] { 24 | matching(P.a) | then(1) | pass 25 | then(1) | pass 26 | } 27 | 28 | let maLine = #line + 1; var maBlock: Syntax.MatchingActions { 29 | matching(P.a) | pass 30 | } 31 | 32 | func outputEntry1() { output("entry1") } 33 | func outputEntry2() { output("entry2") } 34 | func outputExit1() { output("exit1") } 35 | func outputExit2() { output("exit2") } 36 | func output(_ s: String) { output += s } 37 | 38 | var entry1: [AnyAction] { Array(outputEntry1) } 39 | var entry2: [AnyAction] { Array(outputEntry2) } 40 | var exit1: [AnyAction] { Array(outputExit1) } 41 | var exit2: [AnyAction] { Array(outputExit2) } 42 | 43 | func assertMWTAResult( 44 | _ result: [AnyNode], 45 | event: Event = BlockTestsBase.defaultEvent, 46 | expectedOutput eo: String = BlockTestsBase.defaultOutput, 47 | sutFile sf: String? = nil, 48 | xctFile xf: StaticString = #filePath, 49 | sutLine sl: Int, 50 | xctLine xl: UInt = #line 51 | ) async { 52 | let sf = sf == nil ? #file : sf! 53 | 54 | for i in stride(from: 0, to: result.count, by: 2) { 55 | await assertMWTA( 56 | result[i], 57 | event: event, 58 | expectedOutput: eo, 59 | sutFile: sf, 60 | xctFile: xf, 61 | sutLine: sl + i, 62 | xctLine: xl 63 | ) 64 | } 65 | 66 | for i in stride(from: 1, to: result.count, by: 2) { 67 | await assertWTA( 68 | result[i], 69 | event: event, 70 | expectedOutput: eo, 71 | sutFile: sf, 72 | xctFile: xf, 73 | sutLine: sl + i, 74 | xctLine: xl 75 | ) 76 | } 77 | } 78 | 79 | func assertMWAResult( 80 | _ result: [AnyNode], 81 | expectedOutput eo: String = BlockTestsBase.defaultOutput, 82 | sutFile sf: String? = nil, 83 | xctFile xf: StaticString = #filePath, 84 | sutLine sl: Int, 85 | xctLine xl: UInt = #line 86 | ) async { 87 | let sf = sf == nil ? #file : sf! 88 | 89 | for i in stride(from: 0, to: result.count, by: 2) { 90 | await assertMWA( 91 | result[i], 92 | expectedOutput: eo, 93 | sutFile: sf, 94 | xctFile: xf, 95 | sutLine: sl + i, 96 | xctLine: xl 97 | ) 98 | } 99 | 100 | for i in stride(from: 1, to: result.count, by: 2) { 101 | await assertWA( 102 | result[i], 103 | expectedOutput: eo, 104 | sutFile: sf, 105 | xctFile: xf, 106 | sutLine: sl + i, 107 | xctLine: xl 108 | ) 109 | } 110 | } 111 | 112 | func assertMTAResult( 113 | _ result: [AnyNode], 114 | expectedOutput eo: String = BlockTestsBase.defaultOutput, 115 | sutFile sf: String? = nil, 116 | xctFile xf: StaticString = #filePath, 117 | sutLine sl: Int, 118 | xctLine xl: UInt = #line 119 | ) async { 120 | let sf = sf == nil ? #file : sf! 121 | 122 | for i in stride(from: 0, to: result.count, by: 2) { 123 | await assertMTA( 124 | result[i], 125 | expectedOutput: eo, 126 | sutFile: sf, 127 | xctFile: xf, 128 | sutLine: sl + i, 129 | xctLine: xl 130 | ) 131 | } 132 | 133 | for i in stride(from: 1, to: result.count, by: 2) { 134 | await assertTA( 135 | result[i], 136 | expectedOutput: eo, 137 | sutFile: sf, 138 | xctFile: xf, 139 | sutLine: sl + i, 140 | xctLine: xl 141 | ) 142 | } 143 | } 144 | 145 | func assertMAResult( 146 | _ result: [AnyNode], 147 | expectedOutput eo: String = BlockTestsBase.defaultOutput, 148 | xctFile xf: StaticString = #filePath, 149 | sutLine sl: Int, 150 | xctLine xl: UInt = #line 151 | ) async { 152 | for i in 0..], line: UInt = #line) { 165 | let output = nodes.map { $0.resolve().output } 166 | XCTAssertEqual(3, output.count, line: line) 167 | 168 | let defineOutput = output.dropFirst().flattened 169 | defineOutput.forEach { 170 | XCTAssertEqual(defineOutput.first?.overrideGroupID, $0.overrideGroupID, line: line) 171 | } 172 | 173 | XCTAssertNotEqual(output.flattened.first?.overrideGroupID, 174 | output.flattened.last?.overrideGroupID, 175 | line: line) 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/BuilderTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class BuilderTests: BlockTestsBase { 5 | func testMWTA() async { 6 | let line = #line; @MWTABuilder var mwta: [Syntax.MatchingWhenThenActions] { 7 | matching(P.a) | when(1, or: 2) | then(1) | pass 8 | when(1, or: 2) | then(1) | pass 9 | matching(P.a) | when(1, or: 2) | then(1) | passAsync 10 | when(1, or: 2) | then(1) | passAsync 11 | matching(P.a) | when(1, or: 2) | then(1) | passWithEvent 12 | when(1, or: 2) | then(1) | passWithEvent 13 | matching(P.a) | when(1, or: 2) | then(1) | passWithEventAsync 14 | when(1, or: 2) | then(1) | passWithEventAsync 15 | matching(P.a) | when(1, or: 2) | then(1) | pass & pass 16 | when(1, or: 2) | then(1) | pass & pass 17 | } 18 | 19 | await assertMWTA(mwta[0].node, sutLine: line + 1) 20 | await assertWTA(mwta[1].node, sutLine: line + 2) 21 | 22 | await assertMWTA(mwta[2].node, sutLine: line + 3) 23 | await assertWTA(mwta[3].node, sutLine: line + 4) 24 | 25 | await assertMWTA(mwta[4].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 5) 26 | await assertWTA( 27 | mwta[5].node, 28 | expectedOutput: Self.defaultOutputWithEvent, 29 | sutLine: line + 6 30 | ) 31 | 32 | await assertMWTA(mwta[6].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 7) 33 | await assertWTA( 34 | mwta[7].node, 35 | expectedOutput: Self.defaultOutputWithEvent, 36 | sutLine: line + 8 37 | ) 38 | 39 | await assertMWTA(mwta[8].node, 40 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 41 | sutLine: line + 9) 42 | await assertWTA(mwta[9].node, 43 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 44 | sutLine: line + 10) 45 | } 46 | 47 | func testMWA() async { 48 | let line = #line; @MWABuilder var mwa: [Syntax.MatchingWhenActions] { 49 | matching(P.a) | when(1, or: 2) | pass 50 | when(1, or: 2) | pass 51 | matching(P.a) | when(1, or: 2) | passAsync 52 | when(1, or: 2) | passAsync 53 | matching(P.a) | when(1, or: 2) | passWithEvent 54 | when(1, or: 2) | passWithEvent 55 | matching(P.a) | when(1, or: 2) | passWithEventAsync 56 | when(1, or: 2) | passWithEventAsync 57 | matching(P.a) | when(1, or: 2) | pass & pass 58 | when(1, or: 2) | pass & pass 59 | } 60 | 61 | await assertMWA(mwa[0].node, sutLine: line + 1) 62 | await assertWA(mwa[1].node, sutLine: line + 2) 63 | 64 | await assertMWA(mwa[2].node, sutLine: line + 3) 65 | await assertWA(mwa[3].node, sutLine: line + 4) 66 | 67 | await assertMWA(mwa[4].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 5) 68 | await assertWA(mwa[5].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 6) 69 | 70 | await assertMWA(mwa[6].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 7) 71 | await assertWA(mwa[7].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 8) 72 | 73 | await assertMWA(mwa[8].node, 74 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 75 | sutLine: line + 9) 76 | await assertWA(mwa[9].node, 77 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 78 | sutLine: line + 10) 79 | } 80 | 81 | func testMTA() async { 82 | let line = #line; @MTABuilder var mta: [Syntax.MatchingThenActions] { 83 | matching(P.a) | then(1) | pass 84 | then(1) | pass 85 | matching(P.a) | then(1) | passAsync 86 | then(1) | passAsync 87 | matching(P.a) | then(1) | passWithEvent 88 | then(1) | passWithEvent 89 | matching(P.a) | then(1) | passWithEventAsync 90 | then(1) | passWithEventAsync 91 | matching(P.a) | then(1) | pass & pass 92 | then(1) | pass & pass 93 | } 94 | 95 | await assertMTA(mta[0].node, sutLine: line + 1) 96 | await assertTA(mta[1].node, sutLine: line + 2) 97 | 98 | await assertMTA(mta[2].node, sutLine: line + 3) 99 | await assertTA(mta[3].node, sutLine: line + 4) 100 | 101 | await assertMTA(mta[4].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 5) 102 | await assertTA(mta[5].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 6) 103 | 104 | await assertMTA(mta[6].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 7) 105 | await assertTA(mta[7].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 8) 106 | 107 | await assertMTA(mta[8].node, 108 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 109 | sutLine: line + 9) 110 | await assertTA(mta[9].node, 111 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 112 | sutLine: line + 10) 113 | } 114 | 115 | func testMA() async { 116 | let line = #line; @MABuilder var ma: [Syntax.MatchingActions] { 117 | matching(P.a) | pass 118 | matching(P.a) | passAsync 119 | matching(P.a) | passWithEvent 120 | matching(P.a) | passWithEventAsync 121 | matching(P.a) | pass & pass 122 | } 123 | 124 | await assertMA(ma[0].node, sutLine: line + 1) 125 | await assertMA(ma[1].node, sutLine: line + 2) 126 | await assertMA(ma[2].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 3) 127 | await assertMA(ma[3].node, expectedOutput: Self.defaultOutputWithEvent, sutLine: line + 4) 128 | await assertMA(ma[4].node, 129 | expectedOutput: Self.defaultOutput + Self.defaultOutput, 130 | sutLine: line + 5) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/ConditionBlockTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class ConditionBlockTests: BlockTestsBase { 5 | func mbnComponents(of s: Syntax.CompoundSyntax) -> (MatchingBlockNode, MatchingBlockNode) { 6 | let a1 = mbn(s.node) 7 | let a2 = mbn(a1.rest.first!) 8 | return (a1, a2) 9 | } 10 | 11 | func mbn(_ n: any SyntaxNode) -> MatchingBlockNode { 12 | n as! MatchingBlockNode 13 | } 14 | 15 | func assertMWTANode( 16 | _ b: MatchingBlockNode, 17 | expected: Bool, 18 | nodeLine nl: Int, 19 | restLine rl: Int, 20 | xctLine xl: UInt 21 | ) async { 22 | await assertMatchBlock(b, expected: expected, sutLine: nl, xctLine: xl) 23 | await assertMWTAResult(b.rest, sutLine: rl, xctLine: xl) 24 | } 25 | 26 | func assertMWANode( 27 | _ b: MatchingBlockNode, 28 | expected: Bool, 29 | nodeLine nl: Int, 30 | restLine rl: Int, 31 | xctLine xl: UInt 32 | ) async { 33 | await assertMatchBlock(b, expected: expected, sutLine: nl, xctLine: xl) 34 | await assertMWAResult(b.rest, sutLine: rl, xctLine: xl) 35 | } 36 | 37 | func assertMTANode( 38 | _ b: MatchingBlockNode, 39 | expected: Bool, 40 | nodeLine nl: Int, 41 | restLine rl: Int, 42 | xctLine xl: UInt 43 | ) async { 44 | await assertMatchBlock(b, expected: expected, sutLine: nl, xctLine: xl) 45 | await assertMTAResult(b.rest, sutLine: rl, xctLine: xl) 46 | } 47 | 48 | func assertMatchBlock( 49 | _ b: MatchingBlockNode, 50 | expected: Bool, 51 | sutLine sl: Int, 52 | xctLine xl: UInt = #line 53 | ) async { 54 | assertNeverEmptyNode(b, caller: "condition", sutLine: sl, xctLine: xl) 55 | await assertMatchNode(b, condition: expected, caller: "condition", sutLine: sl, xctLine: xl) 56 | } 57 | 58 | func testMWTABlocks() async { 59 | func assertMWTABlock( 60 | _ b: Syntax.MWTA_Group, 61 | condition: Bool, 62 | nodeLine sl: Int, 63 | xctLine xl: UInt = #line 64 | ) async { 65 | await assertMWTANode( 66 | mbn(b.node), 67 | expected: condition, 68 | nodeLine: sl, 69 | restLine: mwtaLine, 70 | xctLine: xl 71 | ) 72 | } 73 | 74 | let l1 = #line; let c1 = condition({ false }) { mwtaBlock } 75 | await assertMWTABlock(c1, condition: false, nodeLine: l1) 76 | } 77 | 78 | func testMWABlocks() async { 79 | func assertMWABlock( 80 | _ b: Syntax.MWA_Group, 81 | condition: Bool, 82 | nodeLine nl: Int, 83 | xctLine xl: UInt = #line 84 | ) async { 85 | await assertMWANode(mbn(b.node), 86 | expected: condition, 87 | nodeLine: nl, 88 | restLine: mwaLine, 89 | xctLine: xl) 90 | } 91 | 92 | let l1 = #line; let c1 = condition({ false }) { mwaBlock } 93 | await assertMWABlock(c1, condition: false, nodeLine: l1) 94 | } 95 | 96 | func testMTABlocks() async { 97 | func assertMTABlock( 98 | _ b: Syntax.MTA_Group, 99 | condition: Bool, 100 | nodeLine nl: Int, 101 | xctLine xl: UInt = #line 102 | ) async { 103 | await assertMTANode(mbn(b.node), 104 | expected: condition, 105 | nodeLine: nl, 106 | restLine: mtaLine, 107 | xctLine: xl) 108 | } 109 | 110 | let l1 = #line; let c1 = condition({ false }) { mtaBlock } 111 | await assertMTABlock(c1, condition: false, nodeLine: l1) 112 | } 113 | 114 | func testCompoundMWTABlocks() async { 115 | func assertCompoundMWTABlock( 116 | _ b: Syntax.MWTA_Group, 117 | condition: Bool, 118 | nodeLine nl: Int, 119 | xctLine xl: UInt = #line 120 | ) async { 121 | let c = mbnComponents(of: b) 122 | 123 | await assertMatchBlock(c.0, expected: condition, sutLine: nl, xctLine: xl) 124 | await assertMWTANode(c.1, 125 | expected: condition, 126 | nodeLine: nl, 127 | restLine: mwtaLine, 128 | xctLine: xl) 129 | } 130 | 131 | let l1 = #line; let c1 = condition({ false }) { condition({ false }) { mwtaBlock } } 132 | await assertCompoundMWTABlock(c1, condition: false, nodeLine: l1) 133 | } 134 | 135 | func testCompoundMWABlocks() async { 136 | func assertCompoundMWABlock( 137 | _ b: Syntax.MWA_Group, 138 | condition: Bool, 139 | nodeLine nl: Int, 140 | xctLine xl: UInt = #line 141 | ) async { 142 | let c = mbnComponents(of: b) 143 | 144 | await assertMatchBlock(c.0, expected: condition, sutLine: nl, xctLine: xl) 145 | await assertMWANode(c.1, 146 | expected: condition, 147 | nodeLine: nl, 148 | restLine: mwaLine, 149 | xctLine: xl) 150 | } 151 | 152 | let l1 = #line; let c1 = condition({ false }) { condition({ false }) { mwaBlock } } 153 | await assertCompoundMWABlock(c1, condition: false, nodeLine: l1) 154 | } 155 | 156 | func testCompoundMTABlocks() async { 157 | func assertCompoundMTABlock( 158 | _ b: Syntax.MTA_Group, 159 | condition: Bool, 160 | nodeLine nl: Int, 161 | xctLine xl: UInt = #line 162 | ) async { 163 | let c = mbnComponents(of: b) 164 | 165 | await assertMatchBlock(c.0, expected: condition, sutLine: nl, xctLine: xl) 166 | await assertMTANode(c.1, 167 | expected: condition, 168 | nodeLine: nl, 169 | restLine: mtaLine, 170 | xctLine: xl) 171 | } 172 | 173 | let l1 = #line; let c1 = condition({ false }) { condition({ false }) { mtaBlock } } 174 | await assertCompoundMTABlock(c1, condition: false, nodeLine: l1) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Nodes/MatchResolvingNode/EagerMRNTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class EagerMatchResolvingNodeTests: MRNTestBase { 5 | struct ExpectedMRNError { 6 | let state: AnyTraceable, 7 | match: MatchDescriptorChain, 8 | predicates: PredicateSet, 9 | event: AnyTraceable, 10 | nextState: AnyTraceable, 11 | actionsOutput: String 12 | } 13 | 14 | typealias Key = EagerMatchResolvingNode.ImplicitClashesKey 15 | 16 | enum P: Predicate { case a, b } 17 | enum Q: Predicate { case a, b } 18 | enum R: Predicate { case a, b } 19 | 20 | func matchResolvingNode(rest: [any SyntaxNode]) -> EMRN { 21 | .init(rest: [SVN(rest: [ARN(rest: rest)])]) 22 | } 23 | 24 | func makeErrorOutput( 25 | _ g: AnyTraceable, 26 | _ m: MatchDescriptorChain, 27 | _ p: [any Predicate], 28 | _ w: AnyTraceable, 29 | _ t: AnyTraceable, 30 | _ a: String = "12" 31 | ) -> ExpectedMRNError { 32 | .init(state: g, 33 | match: m, 34 | predicates: Set(p.erased()), 35 | event: w, 36 | nextState: t, 37 | actionsOutput: a) 38 | } 39 | 40 | func assertError( 41 | _ result: MRNResult, 42 | expected: [ExpectedMRNError], 43 | line: UInt = #line 44 | ) { 45 | guard let clashError = result.errors[0] as? EMRN.ImplicitClashesError else { 46 | XCTFail("unexpected error \(result.errors[0])", line: line) 47 | return 48 | } 49 | 50 | let clashes = clashError.clashes 51 | guard assertCount(clashes.first?.value, expected: expected.count, line: line) else { 52 | return 53 | } 54 | 55 | let errors = clashes.map(\.value).flattened 56 | 57 | expected.forEach { exp in 58 | assertEqual(exp, errors.first { 59 | $0.state == exp.state && 60 | $0.descriptor == exp.match && 61 | $0.event == exp.event && 62 | $0.nextState == exp.nextState 63 | }, line: line) 64 | } 65 | } 66 | 67 | func assertEqual( 68 | _ lhs: ExpectedMRNError?, 69 | _ rhs: EMRN.ErrorOutput?, 70 | line: UInt = #line 71 | ) { 72 | XCTAssertEqual(lhs?.state, rhs?.state, line: line) 73 | XCTAssertEqual(lhs?.match, rhs?.descriptor, line: line) 74 | XCTAssertEqual(lhs?.event, rhs?.event, line: line) 75 | XCTAssertEqual(lhs?.nextState, rhs?.nextState, line: line) 76 | } 77 | 78 | func testEmptyNode() { 79 | let result = matchResolvingNode(rest: []).resolve() 80 | 81 | assertCount(result.output, expected: 0) 82 | assertCount(result.errors, expected: 0) 83 | } 84 | 85 | func testTableWithNoMatches() async { 86 | let d = defineNode(s1, MatchDescriptorChain(), e1, s2) 87 | let result = matchResolvingNode(rest: [d]).resolve() 88 | 89 | assertCount(result.output, expected: 1) 90 | await assertResult( 91 | result, 92 | expected: makeOutput( 93 | c: nil, 94 | g: s1, 95 | m: MatchDescriptorChain(), 96 | p: [], 97 | w: e1, 98 | t: s2 99 | ) 100 | ) 101 | } 102 | 103 | func testMatchCondition() async { 104 | let d = defineNode(s1, MatchDescriptorChain(condition: { false }), e1, s2) 105 | let result = matchResolvingNode(rest: [d]).resolve() 106 | let condition = result.output.first?.condition?() 107 | 108 | XCTAssertEqual(false, condition) 109 | } 110 | 111 | func testImplicitMatch() async { 112 | let d1 = defineNode(s1, MatchDescriptorChain(), e1, s2) 113 | let d2 = defineNode(s1, MatchDescriptorChain(any: Q.a), e1, s3) 114 | let result = matchResolvingNode(rest: [d1, d2]).resolve() 115 | 116 | assertCount(result.output, expected: 2) 117 | 118 | await assertResult( 119 | result, 120 | expected: makeOutput( 121 | c: nil, 122 | g: s1, 123 | m: MatchDescriptorChain(), 124 | p: [Q.b], 125 | w: e1, 126 | t: s2 127 | ) 128 | ) 129 | 130 | await assertResult( 131 | result, expected: makeOutput( 132 | c: nil, 133 | g: s1, 134 | m: MatchDescriptorChain(any: Q.a), 135 | p: [Q.a], 136 | w: e1, 137 | t: s3 138 | ) 139 | ) 140 | } 141 | 142 | func testImplicitMatchClash() { 143 | let d1 = defineNode(s1, MatchDescriptorChain(any: P.a), e1, s2) 144 | let d2 = defineNode(s1, MatchDescriptorChain(any: Q.a), e1, s3) 145 | let result = matchResolvingNode(rest: [d1, d2]).resolve() 146 | 147 | guard assertCount(result.errors, expected: 1) else { return } 148 | guard let clashError = result.errors[0] as? EMRN.ImplicitClashesError else { 149 | XCTFail("unexpected error \(result.errors[0])"); return 150 | } 151 | 152 | guard assertCount(clashError.clashes.first?.value, expected: 2) else { return } 153 | assertError(result, expected: [makeErrorOutput(s1, MatchDescriptorChain(any: P.a), [P.a, Q.a], e1, s2), 154 | makeErrorOutput(s1, MatchDescriptorChain(any: Q.a), [P.a, Q.a], e1, s3)]) 155 | } 156 | 157 | func testMoreSubtleImplicitMatchClashes() throws { 158 | let d1 = defineNode(s1, MatchDescriptorChain(any: P.a, R.a), e1, s2) 159 | let d2 = defineNode(s1, MatchDescriptorChain(any: Q.a), e1, s3) 160 | let d3 = defineNode(s1, MatchDescriptorChain(any: Q.a, S.a), e1, s1) 161 | 162 | let r1 = matchResolvingNode(rest: [d1, d2]).resolve() 163 | let r2 = matchResolvingNode(rest: [d1, d3]).resolve() 164 | 165 | XCTAssertFalse(r1.errors.isEmpty) 166 | XCTAssertFalse(r2.errors.isEmpty) 167 | } 168 | 169 | func testPassesConditionToOutput() async { 170 | let d1 = defineNode(s1, MatchDescriptorChain(condition: { false }), e1, s2) 171 | let result = matchResolvingNode(rest: [d1]).resolve() 172 | 173 | guard assertCount(result.errors, expected: 0) else { return } 174 | guard assertCount(result.output, expected: 1) else { return } 175 | 176 | let condition = result.output.first?.condition?() 177 | XCTAssertEqual(false, condition) 178 | } 179 | } 180 | 181 | -------------------------------------------------------------------------------- /Sources/SwiftFSM/Internal/Nodes/Validation/SemanticValidationNode.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | protocol SVNKey: FSMHashable { 4 | init(_ input: SemanticValidationNode.Input) 5 | } 6 | 7 | class SemanticValidationNode: SyntaxNode { 8 | typealias DuplicatesDictionary = [DuplicatesKey: [Input]] 9 | typealias ClashesDictionary = [ClashesKey: [Input]] 10 | 11 | var rest: [any SyntaxNode] 12 | var errors: [Error] = [] 13 | 14 | init(rest: [any SyntaxNode]) { 15 | self.rest = rest 16 | } 17 | 18 | func combinedWith(_ rest: [OverrideSyntaxDTO]) -> [OverrideSyntaxDTO] { 19 | var duplicates = DuplicatesDictionary() 20 | var clashes = ClashesDictionary() 21 | 22 | var output = rest.reduce(into: [Output]()) { result, row in 23 | func isDuplicate(_ lhs: Input) -> Bool { 24 | isError(lhs, keyType: DuplicatesKey.self) 25 | } 26 | 27 | func isClash(_ lhs: Input) -> Bool { 28 | isError(lhs, keyType: ClashesKey.self) 29 | } 30 | 31 | func isError(_ lhs: Input, keyType: T.Type) -> Bool { 32 | let haveClashingValues = T.init(lhs) == T.init(row) 33 | let haveNoOverrides = !lhs.isOverride && !row.isOverride 34 | let haveOverrides = lhs.isOverride || row.isOverride 35 | let areSameOverrideGroup = lhs.overrideGroupID == row.overrideGroupID 36 | 37 | return haveClashingValues && (haveNoOverrides || haveOverrides && areSameOverrideGroup) 38 | } 39 | 40 | func add(_ existing: Output, row: Output, to dict: inout [T: [Input]]) { 41 | let key = T(row) 42 | dict[key] = (dict[key] ?? [existing]) + [row] 43 | } 44 | 45 | if let dupe = result.first(where: isDuplicate) { 46 | add(dupe, row: row, to: &duplicates) 47 | } else if let clash = result.first(where: isClash) { 48 | add(clash, row: row, to: &clashes) 49 | } 50 | 51 | result.append(row) 52 | } 53 | 54 | if !duplicates.isEmpty { 55 | errors.append(DuplicatesError(duplicates: duplicates)) 56 | } 57 | 58 | if !clashes.isEmpty { 59 | errors.append(ClashError(clashes: clashes)) 60 | } 61 | 62 | output = implementOverrides(in: output) 63 | return errors.isEmpty ? output : [] 64 | } 65 | 66 | private func implementOverrides(in output: [OverrideSyntaxDTO]) -> [OverrideSyntaxDTO] { 67 | var reverseOutput = Array(output.reversed()) 68 | let overrides = reverseOutput.filter(\.isOverride) 69 | guard !overrides.isEmpty else { return output } 70 | 71 | var alreadyOverridden = [OverrideSyntaxDTO]() 72 | 73 | overrides.forEach { override in 74 | func isOverridden(_ candidate: OverrideSyntaxDTO) -> Bool { 75 | candidate.state == override.state && 76 | candidate.descriptor == override.descriptor && 77 | candidate.event == override.event 78 | } 79 | 80 | func implementOverrides() { 81 | func findOutOfPlaceOverrides() -> [OverrideSyntaxDTO]? { 82 | let prefix = Array(reverseOutput.prefix(upTo: indexAfterOverride - 1)) 83 | let outOfPlaceOverrides = prefix.filter(isOverridden) 84 | return outOfPlaceOverrides.isEmpty ? nil : outOfPlaceOverrides 85 | } 86 | 87 | func findSuffixFromOverride() -> [OverrideSyntaxDTO]? { 88 | let suffix = Array(reverseOutput.suffix(from: indexAfterOverride)) 89 | return suffix.contains(where: isOverridden) ? suffix : nil 90 | } 91 | 92 | let indexAfterOverride = reverseOutput.firstIndex { $0 == override }! + 1 93 | 94 | if let outOfPlaceOverrides = findOutOfPlaceOverrides() { 95 | errors.append(OverrideOutOfOrder(override, outOfPlaceOverrides)); return 96 | } 97 | 98 | guard var suffixFromOverride = findSuffixFromOverride() else { 99 | errors.append(NothingToOverride(override)); return 100 | } 101 | 102 | suffixFromOverride.removeAll(where: isOverridden) 103 | reverseOutput.replaceSubrange(indexAfterOverride..., with: suffixFromOverride) 104 | } 105 | 106 | if !alreadyOverridden.contains(where: isOverridden) { 107 | alreadyOverridden.append(override) 108 | implementOverrides() 109 | } 110 | } 111 | 112 | return reverseOutput.reversed() 113 | } 114 | 115 | func findErrors() -> [Error] { 116 | errors 117 | } 118 | } 119 | 120 | extension SemanticValidationNode { 121 | struct DuplicatesError: Error { 122 | let duplicates: DuplicatesDictionary 123 | } 124 | 125 | struct ClashError: Error { 126 | let clashes: ClashesDictionary 127 | } 128 | 129 | class OverrideError: @unchecked Sendable, Error { 130 | let override: OverrideSyntaxDTO 131 | 132 | init(_ override: OverrideSyntaxDTO) { 133 | self.override = override 134 | } 135 | } 136 | 137 | final class OverrideOutOfOrder: OverrideError, @unchecked Sendable { 138 | let outOfOrder: [OverrideSyntaxDTO] 139 | 140 | init(_ override: OverrideSyntaxDTO, _ outOfOrder: [OverrideSyntaxDTO]) { 141 | self.outOfOrder = outOfOrder 142 | super.init(override) 143 | } 144 | } 145 | 146 | final class NothingToOverride: OverrideError, @unchecked Sendable { } 147 | 148 | struct DuplicatesKey: SVNKey { 149 | let state: AnyTraceable, 150 | match: MatchDescriptorChain, 151 | event: AnyTraceable, 152 | nextState: AnyTraceable 153 | 154 | init(_ input: Input) { 155 | state = input.state 156 | match = input.descriptor 157 | event = input.event 158 | nextState = input.nextState 159 | } 160 | } 161 | 162 | struct ClashesKey: SVNKey { 163 | let state: AnyTraceable, 164 | match: MatchDescriptorChain, 165 | event: AnyTraceable 166 | 167 | init(_ input: Input) { 168 | state = input.state 169 | match = input.descriptor 170 | event = input.event 171 | } 172 | } 173 | } 174 | 175 | extension OverrideSyntaxDTO: Equatable { 176 | static func == (lhs: OverrideSyntaxDTO, rhs: OverrideSyntaxDTO) -> Bool { 177 | lhs.state == rhs.state && 178 | lhs.descriptor == rhs.descriptor && 179 | lhs.event == rhs.event && 180 | lhs.nextState == rhs.nextState && 181 | lhs.overrideGroupID == rhs.overrideGroupID && 182 | lhs.isOverride == rhs.isOverride 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/Syntax/CompoundBlockSyntax/MatchingBlockTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | class MatchingBlockTests: BlockTestsBase { 5 | func mbnComponents(of s: Syntax.CompoundSyntax) -> (MatchingBlockNode, MatchingBlockNode) { 6 | let a1 = mbn(s.node) 7 | let a2 = mbn(a1.rest.first!) 8 | return (a1, a2) 9 | } 10 | 11 | func mbn(_ n: any SyntaxNode) -> MatchingBlockNode { 12 | n as! MatchingBlockNode 13 | } 14 | 15 | func assertMWTANode( 16 | _ b: MatchingBlockNode, 17 | any: [any Predicate], 18 | all: [any Predicate], 19 | nodeLine nl: Int, 20 | restLine rl: Int, 21 | xctLine xl: UInt 22 | ) async { 23 | await assertMatchBlock(b, any: any, all: all, sutLine: nl, xctLine: xl) 24 | await assertMWTAResult(b.rest, sutLine: rl, xctLine: xl) 25 | } 26 | 27 | func assertMWANode( 28 | _ b: MatchingBlockNode, 29 | any: [any Predicate], 30 | all: [any Predicate], 31 | nodeLine nl: Int, 32 | restLine rl: Int, 33 | xctLine xl: UInt 34 | ) async { 35 | await assertMatchBlock(b, any: any, all: all, sutLine: nl, xctLine: xl) 36 | await assertMWAResult(b.rest, sutLine: rl, xctLine: xl) 37 | } 38 | 39 | func assertMTANode( 40 | _ b: MatchingBlockNode, 41 | any: [any Predicate], 42 | all: [any Predicate], 43 | nodeLine nl: Int, 44 | restLine rl: Int, 45 | xctLine xl: UInt 46 | ) async { 47 | await assertMatchBlock(b, any: any, all: all, sutLine: nl, xctLine: xl) 48 | await assertMTAResult(b.rest, sutLine: rl, xctLine: xl) 49 | } 50 | 51 | func assertMatchBlock( 52 | _ b: MatchingBlockNode, 53 | any: [any Predicate], 54 | all: [any Predicate], 55 | sutLine sl: Int, 56 | xctLine xl: UInt = #line 57 | ) async { 58 | assertNeverEmptyNode(b, caller: "matching", sutLine: sl, xctLine: xl) 59 | await assertMatchNode(b, any: [any], all: all, sutLine: sl, xctLine: xl) 60 | } 61 | 62 | func testMWTABlocks() async { 63 | func assertMWTABlock( 64 | _ b: Syntax.MWTA_Group, 65 | any: [any Predicate] = [], 66 | all: [any Predicate] = [], 67 | nodeLine sl: Int = #line, 68 | xctLine xl: UInt = #line 69 | ) async { 70 | await assertMWTANode( 71 | mbn(b.node), 72 | any: any, 73 | all: all, 74 | nodeLine: sl, 75 | restLine: mwtaLine, 76 | xctLine: xl 77 | ) 78 | } 79 | 80 | await assertMWTABlock(matching(Q.a) { mwtaBlock }, all: [Q.a]) 81 | await assertMWTABlock(matching(Q.a, and: R.a) { mwtaBlock }, all: [Q.a, R.a]) 82 | await assertMWTABlock(matching(Q.a, or: Q.b) { mwtaBlock }, any: [Q.a, Q.b]) 83 | await assertMWTABlock(matching(Q.a, or: Q.b, and: R.a, S.a) { mwtaBlock }, 84 | any: [Q.a, Q.b], 85 | all: [R.a, S.a]) 86 | } 87 | 88 | func testMWABlocks() async { 89 | func assertMWABlock( 90 | _ b: Syntax.MWA_Group, 91 | any: [any Predicate] = [], 92 | all: [any Predicate] = [], 93 | nodeLine nl: Int = #line, 94 | xctLine xl: UInt = #line 95 | ) async { 96 | await assertMWANode(mbn(b.node), 97 | any: any, 98 | all: all, 99 | nodeLine: nl, 100 | restLine: mwaLine, 101 | xctLine: xl) 102 | } 103 | 104 | await assertMWABlock(matching(Q.a) { mwaBlock }, all: [Q.a]) 105 | await assertMWABlock(matching(Q.a, and: R.a) { mwaBlock }, all: [Q.a, R.a]) 106 | await assertMWABlock(matching(Q.a, or: Q.b) { mwaBlock }, any: [Q.a, Q.b]) 107 | await assertMWABlock(matching(Q.a, or: Q.b, and: R.a, S.a) { mwaBlock }, 108 | any: [Q.a, Q.b], 109 | all: [R.a, S.a]) 110 | } 111 | 112 | func testMTABlocks() async { 113 | func assertMTABlock( 114 | _ b: Syntax.MTA_Group, 115 | any: [any Predicate] = [], 116 | all: [any Predicate] = [], 117 | nodeLine nl: Int = #line, 118 | xctLine xl: UInt = #line 119 | ) async { 120 | await assertMTANode(mbn(b.node), 121 | any: any, 122 | all: all, 123 | nodeLine: nl, 124 | restLine: mtaLine, 125 | xctLine: xl) 126 | } 127 | 128 | await assertMTABlock(matching(Q.a, or: Q.b, and: R.a, S.a) { mtaBlock }, 129 | any: [Q.a, Q.b], 130 | all: [R.a, S.a]) 131 | } 132 | 133 | func testCompoundMWTABlocks() async { 134 | func assertCompoundMWTABlock( 135 | _ b: Syntax.MWTA_Group, 136 | any: [any Predicate] = [], 137 | all: [any Predicate] = [], 138 | nodeLine nl: Int = #line, 139 | xctLine xl: UInt = #line 140 | ) async { 141 | let c = mbnComponents(of: b) 142 | 143 | await assertMatchBlock(c.0, any: any, all: all, sutLine: nl, xctLine: xl) 144 | await assertMWTANode( 145 | c.1, 146 | any: any, 147 | all: all, 148 | nodeLine: nl, 149 | restLine: mwtaLine, 150 | xctLine: xl 151 | ) 152 | } 153 | 154 | await assertCompoundMWTABlock(matching(Q.a) { matching(Q.a) { mwtaBlock } }, all: [Q.a]) 155 | } 156 | 157 | func testCompoundMWABlocks() async { 158 | func assertCompoundMWABlock( 159 | _ b: Syntax.MWA_Group, 160 | any: [any Predicate] = [], 161 | all: [any Predicate] = [], 162 | nodeLine nl: Int = #line, 163 | xctLine xl: UInt = #line 164 | ) async { 165 | let c = mbnComponents(of: b) 166 | 167 | await assertMatchBlock(c.0, any: any, all: all, sutLine: nl, xctLine: xl) 168 | await assertMWANode( 169 | c.1, 170 | any: any, 171 | all: all, 172 | nodeLine: nl, 173 | restLine: mwaLine, 174 | xctLine: xl 175 | ) 176 | } 177 | 178 | await assertCompoundMWABlock(matching(Q.a) { matching(Q.a) { mwaBlock } }, all: [Q.a]) 179 | } 180 | 181 | func testCompoundMTABlocks() async { 182 | func assertCompoundMTABlock( 183 | _ b: Syntax.MTA_Group, 184 | any: [any Predicate] = [], 185 | all: [any Predicate] = [], 186 | nodeLine nl: Int = #line, 187 | xctLine xl: UInt = #line 188 | ) async { 189 | let c = mbnComponents(of: b) 190 | 191 | await assertMatchBlock(c.0, any: any, all: all, sutLine: nl, xctLine: xl) 192 | await assertMTANode( 193 | c.1, 194 | any: any, 195 | all: all, 196 | nodeLine: nl, 197 | restLine: mtaLine, 198 | xctLine: xl 199 | ) 200 | } 201 | 202 | await assertCompoundMTABlock(matching(Q.a) { matching(Q.a) { mtaBlock } }, all: [Q.a]) 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /Tests/SwiftFSMTests/FSM/LoggingTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SwiftFSM 3 | 4 | struct LogData: Equatable { 5 | let event: Int 6 | let predicates: [AnyPredicate] 7 | 8 | init(_ event: Int, _ predicates: [any Predicate]) { 9 | self.event = event 10 | self.predicates = predicates.erased() 11 | } 12 | } 13 | 14 | protocol LoggableFSM { 15 | var loggedEvents: [LogData] { get set } 16 | var loggedTransitions: [Transition] { get set } 17 | } 18 | 19 | class LoggerTests: XCTestCase { 20 | class LoggerSpy: Logger { 21 | var callStack = [String]() 22 | 23 | func appendFunctionName(_ magicName: String) { 24 | callStack.append(String(magicName.prefix { $0 != "(" })) 25 | } 26 | 27 | override func transitionNotFoundString( 28 | _ event: Int, 29 | _ predicates: [any Predicate] 30 | ) -> String { 31 | appendFunctionName(#function) 32 | return super.transitionNotFoundString(event, predicates) 33 | } 34 | 35 | override func transitionNotExecutedString(_ t: Transition) -> String { 36 | appendFunctionName(#function) 37 | return super.transitionNotExecutedString(t) 38 | } 39 | 40 | override func transitionExecutedString(_ t: Transition) -> String { 41 | appendFunctionName(#function) 42 | return super.transitionExecutedString(t) 43 | } 44 | } 45 | 46 | let logger = LoggerSpy() 47 | 48 | func assertStack(_ expected: [String], line: UInt = #line) { 49 | XCTAssertEqual(expected, logger.callStack, line: line) 50 | } 51 | 52 | func testTransitionNotFoundCallsForString() { 53 | logger.transitionNotFound(1, []) 54 | assertStack(["transitionNotFoundString"]) 55 | } 56 | 57 | func testTransitionNotFoundString() { 58 | let output = logger.transitionNotFoundString(1, []) 59 | XCTAssertEqual("no transition found for event '1'", output) 60 | } 61 | 62 | func testTransitionNotFoundStringWithPredicate() { 63 | enum P: Predicate, CustomStringConvertible { 64 | case a; var description: String { "P.a" } 65 | } 66 | 67 | let output = logger.transitionNotFoundString(1, [P.a]) 68 | XCTAssertEqual( 69 | "no transition found for event '1' matching predicates [P.a]", 70 | output 71 | ) 72 | } 73 | 74 | func testTransitionNotExecutedCallsForString() { 75 | logger.transitionNotExecuted(Transition(nil, 1, [], 1, 1, [])) 76 | assertStack(["transitionNotExecutedString"]) 77 | } 78 | 79 | func testTransitionNotExecutedString() { 80 | let output = logger.transitionNotExecutedString(Transition(nil, 1, [], 1, 1, [])) 81 | XCTAssertEqual( 82 | "conditional transition { define(1) | when(1) | then(1) } not executed", 83 | output 84 | ) 85 | } 86 | 87 | func testTransitionNotExecutedStringWithPredicates() { 88 | let output = logger.transitionNotExecutedString(Transition(nil, 1, [P.a.erased()], 1, 1, [])) 89 | XCTAssertEqual( 90 | "conditional transition { define(1) | matching([P.a]) | when(1) | then(1) } not executed", 91 | output 92 | ) 93 | } 94 | 95 | func testTransitionExecutedCallsForString() { 96 | logger.transitionExecuted(Transition(nil, 1, [], 1, 1, [])) 97 | assertStack(["transitionExecutedString"]) 98 | } 99 | 100 | func testTransitionExecutedString() { 101 | let output = logger.transitionExecutedString(Transition(nil, 1, [], 1, 1, [])) 102 | XCTAssertEqual( 103 | "transition { define(1) | when(1) | then(1) } was executed", 104 | output 105 | ) 106 | } 107 | 108 | func testTransitionExecutedStringWithPredicates() { 109 | let output = logger.transitionExecutedString(Transition(nil, 1, [P.a.erased()], 1, 1, [])) 110 | XCTAssertEqual( 111 | "transition { define(1) | matching([P.a]) | when(1) | then(1) } was executed", 112 | output 113 | ) 114 | } 115 | } 116 | 117 | class FSMLoggingTests: XCTestCase, ExpandedSyntaxBuilder { 118 | typealias State = Int 119 | typealias Event = Int 120 | 121 | class FSMSpy: FSM.Eager, LoggableFSM { 122 | var loggedEvents: [LogData] = [] 123 | var loggedTransitions: [Transition] = [] 124 | 125 | override func logTransitionNotFound(_ event: Int, _ predicates: [any Predicate]) { 126 | loggedEvents.append(LogData(event, predicates)) 127 | } 128 | 129 | override func logTransitionNotExecuted(_ t: Transition) { 130 | loggedTransitions.append(t) 131 | } 132 | 133 | override func logTransitionExecuted(_ t: Transition) { 134 | loggedTransitions.append(t) 135 | } 136 | } 137 | 138 | class LazyFSMSpy: FSM.Lazy, LoggableFSM { 139 | var loggedEvents: [LogData] = [] 140 | var loggedTransitions: [Transition] = [] 141 | 142 | override func logTransitionNotFound(_ event: Int, _ predicates: [any Predicate]) { 143 | loggedEvents.append(LogData(event, predicates)) 144 | } 145 | 146 | override func logTransitionNotExecuted(_ t: Transition) { 147 | loggedTransitions.append(t) 148 | } 149 | 150 | override func logTransitionExecuted(_ t: Transition) { 151 | loggedTransitions.append(t) 152 | } 153 | } 154 | 155 | let fsm = FSMSpy(initialState: 1) 156 | let lazyFSM = LazyFSMSpy(initialState: 1) 157 | 158 | func buildTable(@FSM.TableBuilder _ block: () -> [Syntax.Define]) { 159 | try! fsm.buildTable(block) 160 | try! lazyFSM.buildTable(block) 161 | } 162 | 163 | func handleEvent(_ event: Int, _ predicates: any Predicate...) async { 164 | await fsm.handleEvent(event, predicates: predicates) 165 | fsm.state = 1 166 | 167 | await lazyFSM.handleEvent(event, predicates: predicates) 168 | lazyFSM.state = 1 169 | } 170 | 171 | func assertEqual( 172 | _ expected: [T], 173 | _ actual: KeyPath, 174 | line: UInt = #line 175 | ) { 176 | XCTAssertEqual(expected, fsm[keyPath: actual], line: line) 177 | XCTAssertEqual(expected, lazyFSM[keyPath: actual], line: line) 178 | } 179 | 180 | func testTransitionExecutedIsLogged() async { 181 | buildTable { 182 | define(1) { 183 | when(2) | then(3) 184 | } 185 | } 186 | await handleEvent(2) 187 | 188 | let t = Transition(nil, 1, [], 2, 3, []) 189 | assertEqual([t], \.loggedTransitions) 190 | } 191 | 192 | func testTransitionNotFoundIsLogged() async { 193 | enum P: Predicate { case a } 194 | await handleEvent(1, P.a) 195 | assertEqual([LogData(1, [P.a])], \.loggedEvents) 196 | } 197 | 198 | func testTransitionNotExecutedIsLogged() async { 199 | buildTable { 200 | define(1) { 201 | condition({ false }) | when(2) | then(3) 202 | } 203 | } 204 | await handleEvent(2) 205 | 206 | let t = Transition(nil, 1, [], 2, 3, []) 207 | assertEqual([t], \.loggedTransitions) 208 | } 209 | } 210 | 211 | extension Transition: Equatable { 212 | public static func == (lhs: Self, rhs: Self) -> Bool { 213 | lhs.state == rhs.state && 214 | lhs.predicates == rhs.predicates && 215 | lhs.event == rhs.event && 216 | lhs.nextState == rhs.nextState 217 | } 218 | } 219 | --------------------------------------------------------------------------------