From a940f025b6d42697185babf939f6563dacc94d01 Mon Sep 17 00:00:00 2001 From: Farhan Ahmed Date: Tue, 2 May 2023 21:48:12 -0500 Subject: [PATCH] Initial implementation (#1) (#2) * Adding project files * Refactoring and revision of the entire API Also adding example and updating tests * Revised API and readme file * Adding builders for state machine, state, and event --- .github/workflows/preflight-checks.yml | 42 ++ .swiftformat | 17 +- .swiftlint | 106 +++-- AUTHORS | 2 +- CONTRIBUTING.md | 6 +- .../KryptonExample.xcodeproj/project.pbxproj | 378 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../KryptonExample/AlarmService.swift | 177 ++++++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../KryptonExample/ContentView.swift | 58 +++ .../KryptonExample/KryptonExampleApp.swift | 20 + .../Preview Assets.xcassets/Contents.json | 6 + LICENSE | 21 + Package.swift | 22 + README.md | 10 + Sources/Krypton/Builders/EventBuilder.swift | 88 ++++ Sources/Krypton/Builders/KryptonBuilder.swift | 80 ++++ Sources/Krypton/Builders/StateBuilder.swift | 86 ++++ Sources/Krypton/Event.swift | 99 +++++ Sources/Krypton/FiniteStateMachine.swift | 308 ++++++++++++++ Sources/Krypton/State.swift | 104 +++++ Sources/Krypton/Transition.swift | 32 ++ Tests/KryptonTests/KryptonEventTests.swift | 122 ++++++ Tests/KryptonTests/KryptonStateTests.swift | 40 ++ Tests/KryptonTests/KryptonTests.swift | 284 +++++++++++++ Tests/KryptonTests/XCTestManifests.swift | 18 + Tests/LinuxMain.swift | 13 + 30 files changed, 2150 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/preflight-checks.yml create mode 100644 Examples/KryptonExample/KryptonExample.xcodeproj/project.pbxproj create mode 100644 Examples/KryptonExample/KryptonExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/KryptonExample/KryptonExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/KryptonExample/KryptonExample/AlarmService.swift create mode 100644 Examples/KryptonExample/KryptonExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/KryptonExample/KryptonExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/KryptonExample/KryptonExample/Assets.xcassets/Contents.json create mode 100644 Examples/KryptonExample/KryptonExample/ContentView.swift create mode 100644 Examples/KryptonExample/KryptonExample/KryptonExampleApp.swift create mode 100644 Examples/KryptonExample/KryptonExample/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 LICENSE create mode 100644 Package.swift create mode 100755 README.md create mode 100644 Sources/Krypton/Builders/EventBuilder.swift create mode 100644 Sources/Krypton/Builders/KryptonBuilder.swift create mode 100644 Sources/Krypton/Builders/StateBuilder.swift create mode 100644 Sources/Krypton/Event.swift create mode 100644 Sources/Krypton/FiniteStateMachine.swift create mode 100644 Sources/Krypton/State.swift create mode 100644 Sources/Krypton/Transition.swift create mode 100644 Tests/KryptonTests/KryptonEventTests.swift create mode 100644 Tests/KryptonTests/KryptonStateTests.swift create mode 100644 Tests/KryptonTests/KryptonTests.swift create mode 100644 Tests/KryptonTests/XCTestManifests.swift create mode 100644 Tests/LinuxMain.swift diff --git a/.github/workflows/preflight-checks.yml b/.github/workflows/preflight-checks.yml new file mode 100644 index 0000000..f105654 --- /dev/null +++ b/.github/workflows/preflight-checks.yml @@ -0,0 +1,42 @@ +name: Preflight Code Checks + +on: pull_request + +jobs: + Preflight-Code-Checks: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - name: Check for Changes + id: repo-fetch-changes + run: | + echo "-> Source: ${{ github.head_ref }} (${{ github.event.pull_request.head.sha }})" + echo "-> Target: ${{ github.base_ref }} (${{ github.event.pull_request.base.sha }})" + # Fetching base ref + git fetch --prune --no-tags --depth=1 origin +refs/heads/${{ github.base_ref }}:refs/heads/${{ github.base_ref }} + + against=${{ github.event.pull_request.base.sha }} + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + + echo "changed_files<<$EOF" >> $GITHUB_ENV + + changed_files=$(git --no-pager diff-index --cached --diff-filter=ACMR --name-only --relative $against -- '*.swift') + + echo "$changed_files" >> $GITHUB_ENV + echo "$EOF" >> $GITHUB_ENV + + - name: Code Format Validation + if: env.changed_files != '' + run: | + modified_file_list="${{ github.workspace }}/modified_file_list" + + echo "${{ env.changed_files }}" > $modified_file_list + + swiftformat --filelist "$modified_file_list" --lint --config .swiftformat --swiftversion 5 --reporter github-actions-log + + - name: Linting Code + if: env.changed_files != '' + run: | + modified_files=$(cat "${{ github.workspace }}/modified_file_list") + + swiftlint lint --config .swiftlint --reporter "github-actions-logging" $modified_files diff --git a/.swiftformat b/.swiftformat index 1f7c9b0..703dc6c 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,10 +1,17 @@ ---exclude Tests, Scripts, Documentation +# format options +--exclude DerivedData, Documentation, Scripts, **/Package.swift + +--disable redundantType + +--indent 4 --allman true ---semicolons never ---wraparguments after-first +--elseposition next-line --indentcase true +--patternlet inline +--semicolons never +--shortoptionals always +--stripunusedargs always --trimwhitespace always +--wraparguments before-first --wrapcollections before-first ---elseposition next-line ---patternlet inline diff --git a/.swiftlint b/.swiftlint index 66452c3..f48fdc5 100644 --- a/.swiftlint +++ b/.swiftlint @@ -1,48 +1,104 @@ excluded: - - Tests + - DerivedData + - Documentation + - Scripts disabled_rules: - - colon - opening_brace + - trailing_comma - closing_brace - statement_position - - discarded_notification_center_observer - - switch_case_alignment - - identifier_name - - unused_setter_value - - multiple_closures_with_trailing_closure opt_in_rules: - - private_outlet - - private_action - - anyobject_protocol - - collection_alignment - - convenience_type - - discouraged_object_literal - - discouraged_optional_collection - - force_unwrapping - - fatal_error_message - - fallthrough - - empty_string + - contains_over_first_not_nil - empty_count + - empty_string + - fallthrough + - fatal_error_message + - first_where + - force_unwrapping + - private_action + - private_outlet - sorted_imports -line_length: 150 +analyzer_rules: + - unused_import + +void_function_in_ternary: + severity: error +first_where: + severity: error +force_unwrapping: + severity: error +private_outlet: + severity: error +private_action: + severity: error +multiple_closures_with_trailing_closure: + severity: error +notification_center_detachment: + severity: error +empty_enum_arguments: + severity: error +empty_parentheses_with_trailing_closure: + severity: error +redundant_objc_attribute: + severity: error +fatal_error_message: + severity: error +fallthrough: + severity: error +empty_string: + severity: error +empty_count: + severity: error +return_arrow_whitespace: + severity: error +vertical_parameter_alignment: + severity: error +trailing_whitespace: + severity: error +vertical_whitespace: + severity: error + +nesting: + type_level: 2 function_parameter_count: warning: 4 error: 5 + ignores_default_parameters: false file_length: - warning: 450 - error: 500 + warning: 500 + error: 600 + +line_length: + warning: 170 + error: 180 + ignores_comments: true type_body_length: - warning: 300 + warning: 250 error: 400 -closure_body_length: - warning: 80 - error: 100 +type_name: + allowed_symbols: ["_"] + max_length: + warning: 50 + error: 60 + +identifier_name: + allowed_symbols: ["_"] + excluded: ["id", "iv", "ok"] + severity: error + +function_body_length: + warning: 100 + error: 110 + +cyclomatic_complexity: + warning: 10 + error: 20 reporter: "xcode" diff --git a/AUTHORS b/AUTHORS index eea526b..e21a184 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,4 +1,4 @@ -Helium is created, developed, and maintained by Farhan Ahmed. +Krypton is created, developed, and maintained by Farhan Ahmed. A full list of contributors is available from git with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 701b00e..f1c36ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ -How to contribute to Helium -=========================== +How to contribute to Krypton +============================ -Thank you for considering contributing to Helium! +Thank you for considering contributing to Krypton! Reporting issues ---------------- diff --git a/Examples/KryptonExample/KryptonExample.xcodeproj/project.pbxproj b/Examples/KryptonExample/KryptonExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..02bf27b --- /dev/null +++ b/Examples/KryptonExample/KryptonExample.xcodeproj/project.pbxproj @@ -0,0 +1,378 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 08CE570F297B1CB5008F8039 /* KryptonExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CE570E297B1CB5008F8039 /* KryptonExampleApp.swift */; }; + 08CE5711297B1CB5008F8039 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CE5710297B1CB5008F8039 /* ContentView.swift */; }; + 08CE5713297B1CB6008F8039 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 08CE5712297B1CB6008F8039 /* Assets.xcassets */; }; + 08CE5716297B1CB6008F8039 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 08CE5715297B1CB6008F8039 /* Preview Assets.xcassets */; }; + 08CE571F297B1D90008F8039 /* AlarmService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CE571E297B1D90008F8039 /* AlarmService.swift */; }; + 620180F0297B5B35008022E5 /* Krypton in Frameworks */ = {isa = PBXBuildFile; productRef = 620180EF297B5B35008022E5 /* Krypton */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 08CE570B297B1CB5008F8039 /* KryptonExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KryptonExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 08CE570E297B1CB5008F8039 /* KryptonExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KryptonExampleApp.swift; sourceTree = ""; }; + 08CE5710297B1CB5008F8039 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 08CE5712297B1CB6008F8039 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 08CE5715297B1CB6008F8039 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 08CE571D297B1CDB008F8039 /* Krypton */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Krypton; path = ../..; sourceTree = ""; }; + 08CE571E297B1D90008F8039 /* AlarmService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmService.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 08CE5708297B1CB5008F8039 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 620180F0297B5B35008022E5 /* Krypton in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 08CE5702297B1CB5008F8039 = { + isa = PBXGroup; + children = ( + 08CE571C297B1CDB008F8039 /* Packages */, + 08CE570D297B1CB5008F8039 /* KryptonExample */, + 08CE570C297B1CB5008F8039 /* Products */, + 620180EE297B5B35008022E5 /* Frameworks */, + ); + sourceTree = ""; + }; + 08CE570C297B1CB5008F8039 /* Products */ = { + isa = PBXGroup; + children = ( + 08CE570B297B1CB5008F8039 /* KryptonExample.app */, + ); + name = Products; + sourceTree = ""; + }; + 08CE570D297B1CB5008F8039 /* KryptonExample */ = { + isa = PBXGroup; + children = ( + 08CE570E297B1CB5008F8039 /* KryptonExampleApp.swift */, + 08CE5710297B1CB5008F8039 /* ContentView.swift */, + 08CE571E297B1D90008F8039 /* AlarmService.swift */, + 08CE5712297B1CB6008F8039 /* Assets.xcassets */, + 08CE5714297B1CB6008F8039 /* Preview Content */, + ); + path = KryptonExample; + sourceTree = ""; + }; + 08CE5714297B1CB6008F8039 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 08CE5715297B1CB6008F8039 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 08CE571C297B1CDB008F8039 /* Packages */ = { + isa = PBXGroup; + children = ( + 08CE571D297B1CDB008F8039 /* Krypton */, + ); + name = Packages; + sourceTree = ""; + }; + 620180EE297B5B35008022E5 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 08CE570A297B1CB5008F8039 /* KryptonExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 08CE5719297B1CB6008F8039 /* Build configuration list for PBXNativeTarget "KryptonExample" */; + buildPhases = ( + 08CE5707297B1CB5008F8039 /* Sources */, + 08CE5708297B1CB5008F8039 /* Frameworks */, + 08CE5709297B1CB5008F8039 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = KryptonExample; + packageProductDependencies = ( + 620180EF297B5B35008022E5 /* Krypton */, + ); + productName = KryptonExample; + productReference = 08CE570B297B1CB5008F8039 /* KryptonExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 08CE5703297B1CB5008F8039 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1420; + TargetAttributes = { + 08CE570A297B1CB5008F8039 = { + CreatedOnToolsVersion = 14.2; + }; + }; + }; + buildConfigurationList = 08CE5706297B1CB5008F8039 /* Build configuration list for PBXProject "KryptonExample" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 08CE5702297B1CB5008F8039; + productRefGroup = 08CE570C297B1CB5008F8039 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 08CE570A297B1CB5008F8039 /* KryptonExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 08CE5709297B1CB5008F8039 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 08CE5716297B1CB6008F8039 /* Preview Assets.xcassets in Resources */, + 08CE5713297B1CB6008F8039 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 08CE5707297B1CB5008F8039 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 08CE5711297B1CB5008F8039 /* ContentView.swift in Sources */, + 08CE570F297B1CB5008F8039 /* KryptonExampleApp.swift in Sources */, + 08CE571F297B1D90008F8039 /* AlarmService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 08CE5717297B1CB6008F8039 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 08CE5718297B1CB6008F8039 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 08CE571A297B1CB6008F8039 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"KryptonExample/Preview Content\""; + DEVELOPMENT_TEAM = UP83QUUMHT; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.themacronaut.KryptonExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 08CE571B297B1CB6008F8039 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"KryptonExample/Preview Content\""; + DEVELOPMENT_TEAM = UP83QUUMHT; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.themacronaut.KryptonExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 08CE5706297B1CB5008F8039 /* Build configuration list for PBXProject "KryptonExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 08CE5717297B1CB6008F8039 /* Debug */, + 08CE5718297B1CB6008F8039 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 08CE5719297B1CB6008F8039 /* Build configuration list for PBXNativeTarget "KryptonExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 08CE571A297B1CB6008F8039 /* Debug */, + 08CE571B297B1CB6008F8039 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 620180EF297B5B35008022E5 /* Krypton */ = { + isa = XCSwiftPackageProductDependency; + productName = Krypton; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 08CE5703297B1CB5008F8039 /* Project object */; +} diff --git a/Examples/KryptonExample/KryptonExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/KryptonExample/KryptonExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/KryptonExample/KryptonExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/KryptonExample/KryptonExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/KryptonExample/KryptonExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Examples/KryptonExample/KryptonExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/KryptonExample/KryptonExample/AlarmService.swift b/Examples/KryptonExample/KryptonExample/AlarmService.swift new file mode 100644 index 0000000..25364c0 --- /dev/null +++ b/Examples/KryptonExample/KryptonExample/AlarmService.swift @@ -0,0 +1,177 @@ +// +// AlarmService.swift +// KryptonExample +// +// Created by Farhan Ahmed on 1/20/23. +// + +import Foundation +import Krypton + +final class AlarmService: ObservableObject +{ + private let fsm: Krypton + private let code: String + + @Published + private(set) var current_state: State + + init(code: String) + { + self.code = code + + fsm = Self.create_fsm() + + fsm.activate() + + current_state = fsm.current_state + } + + func can_trigger(event: Events) -> Bool + { + do + { + let event = try event.event(in: fsm) + + return fsm.can_fire(event: event) + } + catch + { + return false + } + } +} + +extension AlarmService +{ + enum Events: String + { + case arm = "Arm" + case disarm = "Disarm" + case reset = "Reset" + case breach = "Breach" + case panic = "Panic" + + func event(in fsm: Krypton) throws -> Event + { + try fsm.event(named: rawValue).get() + } + } + + func arm() + { + process(event: .arm) + } + + func disarm(code: String) + { + if code == self.code + { + process(event: .disarm) + } + else + { + debugPrint("Incorrect code entered.") + } + } + + func breach() + { + process(event: .breach) + } + + func panic() + { + process(event: .panic) + } + + func reset(code: String) + { + if code == self.code + { + process(event: .reset) + } + else + { + debugPrint("Incorrect code entered.") + } + } +} + +extension AlarmService +{ + private func process(event: AlarmService.Events, user_info: Payload = [:]) + { + do + { + let event = try event.event(in: fsm) + + try fsm.fire(event: event, user_info: user_info) + + current_state = fsm.current_state + } + catch + { + debugPrint(error) + } + } +} + +extension AlarmService +{ + static func create_fsm() -> Krypton + { + do + { + let (states, events, initial_state) = try states_and_events() + let fsm = try Krypton(initial_state: initial_state) + + fsm.add(states: states) + fsm.add(events: events) + + return fsm + } + catch + { + fatalError("Oh, Snap! We could not create the state machine.") + } + } + + private struct StatesAndEvents + { + let states: Set + let events: Set + let initial: State + } + + private static func states_and_events() throws -> StatesAndEvents + { + let state_transitions = State.Context( + will_enter: { context, _ in print("[Will Enter] \(context)") }, + did_enter: { context, _ in print("[Did Enter] \(context)") }, + will_exit: { context, _ in print("[Will Exit] \(context)") }, + did_exit: { context, _ in print("[Did Exit] \(context)") } + ) + + let event_transitions = Event.TransitionContext( + will_fire: { context, _ in print("[Will Fire] \(context)") }, + did_fire: { context, _ in print("[Did Fire] \(context)") } + ) + + let state_armed = try State(name: "Armed", transition_context: state_transitions) + let state_disarmed = try State(name: "Disarmed", transition_context: state_transitions) + let state_alarm = try State(name: "Alarm", transition_context: state_transitions) + + let event_arm = try Event(name: Events.arm.rawValue, sources: [state_disarmed], destination: state_armed, transition_context: event_transitions) + let event_disarm = try Event(name: Events.disarm.rawValue, sources: [state_armed], destination: state_disarmed, transition_context: event_transitions) + let event_breach = try Event(name: Events.breach.rawValue, sources: [state_armed], destination: state_alarm, transition_context: event_transitions) + let event_panic = try Event(name: Events.panic.rawValue, sources: [state_armed], destination: state_alarm, transition_context: event_transitions) + let event_reset = try Event(name: Events.reset.rawValue, sources: [state_alarm], destination: state_disarmed, transition_context: event_transitions) + + return ( + states: [state_armed, state_disarmed, state_alarm], + events: [event_arm, event_disarm, event_reset, event_breach, event_panic], + initial: state_disarmed + ) + } +} diff --git a/Examples/KryptonExample/KryptonExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/KryptonExample/KryptonExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/KryptonExample/KryptonExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/KryptonExample/KryptonExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/KryptonExample/KryptonExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Examples/KryptonExample/KryptonExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/KryptonExample/KryptonExample/Assets.xcassets/Contents.json b/Examples/KryptonExample/KryptonExample/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/KryptonExample/KryptonExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/KryptonExample/KryptonExample/ContentView.swift b/Examples/KryptonExample/KryptonExample/ContentView.swift new file mode 100644 index 0000000..05a6dd9 --- /dev/null +++ b/Examples/KryptonExample/KryptonExample/ContentView.swift @@ -0,0 +1,58 @@ +// +// ContentView.swift +// KryptonExample +// +// Created by Farhan Ahmed on 1/20/23. +// + +import SwiftUI + +struct ContentView: View +{ + @ObservedObject var service: AlarmService + @State private var alarm_code = "" + + var body: some View + { + VStack(alignment: .leading) + { + TextField("Alarm Code", text: $alarm_code) + + Divider() + + Text("State: \(service.current_state.rawValue)") + .padding(.vertical) + + Divider() + + HStack(spacing: 20) + { + Spacer() + + Button(action: { service.arm() }, label: { Text("Arm") }) + .disabled(!service.can_trigger(event: .arm)) + + Button(action: { service.disarm(code: alarm_code) }, label: { Text("Disarm") }) + .disabled(!service.can_trigger(event: .disarm)) + + Button(action: { service.reset(code: alarm_code) }, label: { Text("Reset") }) + .disabled(!service.can_trigger(event: .reset)) + + Button(action: { service.panic() }, label: { Text("Panic") }) + .disabled(!service.can_trigger(event: .panic)) + + Spacer() + } + .padding(.vertical, 20) + } + .padding() + } +} + +struct ContentView_Previews: PreviewProvider +{ + static var previews: some View + { + ContentView(service: AlarmService(code: "123456")) + } +} diff --git a/Examples/KryptonExample/KryptonExample/KryptonExampleApp.swift b/Examples/KryptonExample/KryptonExample/KryptonExampleApp.swift new file mode 100644 index 0000000..f8a55d2 --- /dev/null +++ b/Examples/KryptonExample/KryptonExample/KryptonExampleApp.swift @@ -0,0 +1,20 @@ +// +// KryptonExampleApp.swift +// KryptonExample +// +// Created by Farhan Ahmed on 1/20/23. +// + +import SwiftUI + +@main +struct KryptonExampleApp: App +{ + var body: some Scene + { + WindowGroup + { + ContentView(service: AlarmService(code: "123456")) + } + } +} diff --git a/Examples/KryptonExample/KryptonExample/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/KryptonExample/KryptonExample/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/KryptonExample/KryptonExample/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..705a0a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016-2020 Farhan Ahmed + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..348e04e --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.7 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Krypton", + platforms: [ + .macOS(.v10_13), + .iOS(.v12), + .tvOS(.v12), + .watchOS(.v4) + ], + products: [ + .library(name: "Krypton", targets: ["Krypton"]) + ], + dependencies: [], + targets: [ + .target(name: "Krypton", dependencies: []), + .testTarget(name: "KryptonTests", + dependencies: ["Krypton"]) + ]) diff --git a/README.md b/README.md new file mode 100755 index 0000000..b5c675d --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# Krypton +## A Lightweight and Elegant State Machine + +### Features + +- Zero external dependencies and lightweight +- Unrestricted number of states and events +- Rich set of life cycle closures for both state and event +- Ability to broadcast arbitrary metadata during transitions +- 90+% unit test coverage giving you confidense in the library diff --git a/Sources/Krypton/Builders/EventBuilder.swift b/Sources/Krypton/Builders/EventBuilder.swift new file mode 100644 index 0000000..da9adfe --- /dev/null +++ b/Sources/Krypton/Builders/EventBuilder.swift @@ -0,0 +1,88 @@ +// +// EventBuilder.swift +// Krypton +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +import Foundation + +public final class EventBuilder +{ + private var event_name: String + private var source_states: Set + private var destination_state: StateMachine.State? + private var context_action_should_fire: StateMachine.Event.TransitionTriggerValidation? + private var context_action_will_fire: StateMachine.TransitionContextAction? + private var context_action_did_fire: StateMachine.TransitionContextAction? + + public init() + { + event_name = "" + source_states = [] + } + + public func name(_ value: String) -> Self + { + event_name = value + + return self + } + + public func source(state: StateMachine.State) -> Self + { + source_states.insert(state) + + return self + } + + public func destination(state: StateMachine.State) -> Self + { + destination_state = state + + return self + } + + public func event_validation(_ action: @escaping StateMachine.Event.TransitionTriggerValidation) -> Self + { + context_action_should_fire = action + + return self + } + + public func action_will_fire(_ action: @escaping StateMachine.TransitionContextAction) -> Self + { + context_action_will_fire = action + + return self + } + + public func action_did_fire(_ action: @escaping StateMachine.TransitionContextAction) -> Self + { + context_action_did_fire = action + + return self + } + + public func build() throws -> StateMachine.Event + { + guard !event_name.isEmpty, + !source_states.isEmpty, + let destination_state = destination_state + else + { + throw StateMachine.Error.invalid_event + } + + return try StateMachine.Event( + name: event_name, + sources: source_states, + destination: destination_state, + transition_context: StateMachine.Event.TransitionContext( + should_fire: context_action_should_fire, + will_fire: context_action_will_fire, + did_fire: context_action_did_fire + ) + ) + } +} diff --git a/Sources/Krypton/Builders/KryptonBuilder.swift b/Sources/Krypton/Builders/KryptonBuilder.swift new file mode 100644 index 0000000..09bacbd --- /dev/null +++ b/Sources/Krypton/Builders/KryptonBuilder.swift @@ -0,0 +1,80 @@ +// +// KryptonBuilder.swift +// Krypton +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +import Foundation + +public final class KryptonBuilder +{ + private var states: Set + private var events: Set + private var starting_state: StateMachine.State? + + init() + { + states = [] + events = [] + } + + func state(_ value: StateMachine.State) -> Self + { + states.insert(value) + + return self + } + + func states(_ values: StateMachine.State...) -> Self + { + values.forEach + { item in + states.insert(item) + } + + return self + } + + func event(_ value: StateMachine.Event) -> Self + { + events.insert(value) + + return self + } + + func events(_ values: StateMachine.Event...) -> Self + { + values.forEach + { item in + events.insert(item) + } + + return self + } + + func initial_state(_ value: StateMachine.State) -> Self + { + starting_state = value + + return self + } + + func build() throws -> StateMachine + { + guard !states.isEmpty, + !events.isEmpty, + let starting_state = starting_state + else + { + throw StateMachine.Error.declined(message: "Validtion failed during state machine creation.") + } + + let fsm = try StateMachine(initial_state: starting_state) + + fsm.add(states: states) + fsm.add(events: events) + + return fsm + } +} diff --git a/Sources/Krypton/Builders/StateBuilder.swift b/Sources/Krypton/Builders/StateBuilder.swift new file mode 100644 index 0000000..b794df2 --- /dev/null +++ b/Sources/Krypton/Builders/StateBuilder.swift @@ -0,0 +1,86 @@ +// +// StateBuilder.swift +// Krypton +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +import Foundation + +public final class StateBuilder +{ + private var state_name: String + private var user_info: StateMachine.Payload + private var context_action_will_enter: StateMachine.TransitionContextAction? + private var context_action_did_enter: StateMachine.TransitionContextAction? + private var context_action_will_exit: StateMachine.TransitionContextAction? + private var context_action_did_exit: StateMachine.TransitionContextAction? + + public init() + { + state_name = "" + user_info = [:] + } + + public func name(_ value: String) -> Self + { + state_name = value + + return self + } + + public func payload(_ value: StateMachine.Payload) -> Self + { + user_info = value + + return self + } + + public func action_will_enter(_ action: @escaping StateMachine.TransitionContextAction) -> Self + { + context_action_will_enter = action + + return self + } + + public func action_did_enter(_ action: @escaping StateMachine.TransitionContextAction) -> Self + { + context_action_did_enter = action + + return self + } + + public func action_will_exit(_ action: @escaping StateMachine.TransitionContextAction) -> Self + { + context_action_will_exit = action + + return self + } + + public func action_did_exit(_ action: @escaping StateMachine.TransitionContextAction) -> Self + { + context_action_did_exit = action + + return self + } + + public func build() throws -> StateMachine.State + { + guard !state_name.isEmpty + else + { + throw StateMachine.Error.invalid_state + } + + return try StateMachine.State( + name: state_name, + user_info: user_info, + transition_context: StateMachine.State.Context( + will_enter: context_action_will_enter, + did_enter: context_action_did_enter, + will_exit: context_action_will_exit, + did_exit: context_action_did_exit + ) + ) + } +} diff --git a/Sources/Krypton/Event.swift b/Sources/Krypton/Event.swift new file mode 100644 index 0000000..e6ca639 --- /dev/null +++ b/Sources/Krypton/Event.swift @@ -0,0 +1,99 @@ +// +// Event.swift +// Krypton +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +import Foundation + +public extension StateMachine +{ + struct Event + { + public typealias TransitionTriggerValidation = (_ event: Event, _ transition: Transition) -> Bool + + public struct TransitionContext + { + private(set) var should_fire: TransitionTriggerValidation? + private(set) var will_fire: TransitionContextAction? + private(set) var did_fire: TransitionContextAction? + + public init( + should_fire: TransitionTriggerValidation? = nil, + will_fire: TransitionContextAction? = nil, + did_fire: TransitionContextAction? = nil + ) + { + self.should_fire = should_fire + self.will_fire = will_fire + self.did_fire = did_fire + } + } + + public let name: String + public let sources: Set + public let destination: State + + let transition_context: TransitionContext? + + public init( + name: String, + sources: Set, + destination: State, + transition_context: TransitionContext = TransitionContext() + ) throws + { + guard !name.isEmpty + else + { + throw StateMachine.Error.invalid_event + } + + self.name = name + self.sources = sources + self.destination = destination + self.transition_context = transition_context + } + } +} + +extension StateMachine.Event: Hashable +{ + public static func == (lhs: StateMachine.Event, rhs: StateMachine.Event) -> Bool + { + return lhs.name == rhs.name + } + + public func hash(into hasher: inout Hasher) + { + hasher.combine(name) + } +} + +extension StateMachine.Event: CustomStringConvertible +{ + public var description: String + { + var source_states = "" + let sorted_sources = sources.sorted() + + for (index, state) in sorted_sources.enumerated() + { + if sources.count == 1 + { + source_states = "\(state.name) " + } + else if index == sources.count - 2 + { + source_states += "\(state.name), and " + } + else + { + source_states += "\(state.name), " + } + } + + return "Triggered: Event `\(name)` | transition: \(source_states) -> \(destination.name)" + } +} diff --git a/Sources/Krypton/FiniteStateMachine.swift b/Sources/Krypton/FiniteStateMachine.swift new file mode 100644 index 0000000..78f5aa0 --- /dev/null +++ b/Sources/Krypton/FiniteStateMachine.swift @@ -0,0 +1,308 @@ +// +// StateMachine.swift +// Krypton +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +import Foundation + +protocol FiniteStateMachine +{ + associatedtype States + associatedtype Events + + var is_active: Bool { get } + var current_state: StateMachine.State { get } + var initial_state: StateMachine.State { get } + + func activate() + func can_fire(event: StateMachine.Event) -> Bool + func fire(event: StateMachine.Event, user_info: StateMachine.Payload) throws +} + +public extension StateMachine +{ + typealias Payload = [String: Any] + typealias TransitionContextAction = (_ context: Context, _ transition: Transition?) -> Void + + enum Error: Swift.Error + { + case not_found + case not_activated + case cannot_fire(message: String) + case declined(message: String) + case invalid_state + case invalid_event + } +} + +public class StateMachine: FiniteStateMachine +{ + typealias States = [String: State] + typealias Events = [String: Event] + + private(set) var states: States + private(set) var events: Events + private(set) var initial_state: State + public private(set) var is_active: Bool + public private(set) var current_state: State + + public init(initial_state: State) throws + { + self.initial_state = initial_state + states = [:] + events = [:] + is_active = false + current_state = try State(name: "Starting-State") + } + + public func add(state: State) + { + guard !is_active + else + { + return + } + + if case .failure = self.state(named: state.name) + { + states[state.name] = state + } + else + { + // Nothing else needs to be done. + } + } + + public func add(states: Set) + { + guard + !is_active, + !states.isEmpty + else + { + return + } + + states.forEach + { state in + self.add(state: state) + } + } + + public func add(event: Event) + { + guard !is_active + else + { + return + } + + if case .failure = self.event(named: event.name) + { + events[event.name] = event + } + else + { + // Nothing to do + } + } + + public func add(events: Set) + { + guard !is_active + else + { + return + } + + events.forEach + { event in + self.add(event: event) + } + } + + public func state(named: String) -> Result + { + let result: Result + + if let foundState = states[named] + { + result = Result.success(foundState) + } + else + { + result = Result.failure(Error.not_found) + } + + return result + } + + public func event(named: String) -> Result + { + let result: Result + + if let foundEvent = events[named] + { + result = Result.success(foundEvent) + } + else + { + result = Result.failure(Error.not_found) + } + + return result + } + + public func isIn(state: State) -> Bool + { + return current_state == state + } + + public func activate() + { + guard !is_active + else + { + return + } + + is_active = true + + // Invoke lifecycle events + if let block = initial_state.transition_context?.will_enter + { + block(initial_state, nil) + } + else + { + // Nothing to do. + } + + current_state = initial_state + + if let block = initial_state.transition_context?.did_enter + { + block(initial_state, nil) + } + else + { + // Nothing to do. + } + } + + public func can_fire(event: Event) -> Bool + { + return event.sources.isEmpty || event.sources.contains(current_state) + } + + public func fire(event: Event, user_info: Payload = [:]) throws + { + guard is_active + else + { + throw Error.not_activated + } + + // Check if the transition is permitted + if !can_fire(event: event) + { + let message = "An attempt was made to fire the `\(event.name)` event " + + "while in the `\(current_state.name)` state. This event can " + + "only be fired from the following states: \(event.sources)" + + throw Error.cannot_fire(message: message) + } + else + { + // Nothing to do. + } + + let transition = Transition(event: event, source: current_state, in: self, user_info: user_info) + + if let should_file = event.transition_context?.should_fire, + !should_file(event, transition) + { + let message = "An attempt to fire the `\(event.name)` event was declined " + + "because `shouldFire` method returned `false`." + + throw Error.declined(message: message) + } + else + { + // When the `should_fire` closure is not provided, that + // is the same as if it has returned the value `true`. + // therefore the event will be triggered and all + // associated lifecycle closures will be invoked, if available. + } + + let old_state = current_state + let new_state = event.destination + + event_transition(event: event, transition: transition, block: event.transition_context?.will_fire) + state_transition(state: old_state, transition: transition, block: old_state.transition_context?.will_exit) + + event_transition(event: event, transition: transition, block: event.transition_context?.did_fire) + + state_transition(state: old_state, transition: transition, block: old_state.transition_context?.did_exit) + state_transition(state: new_state, transition: transition, block: new_state.transition_context?.will_enter) + + current_state = new_state + + state_transition(state: new_state, transition: transition, block: new_state.transition_context?.did_enter) + } + + private func event_transition(event: Event, transition: Transition, block: TransitionContextAction?) + { + if let block = block + { + block(event, transition) + } + else + { + // Nothing to do. + } + } + + private func state_transition(state: State, transition: Transition?, block: TransitionContextAction?) + { + if let block = block + { + block(state, transition) + } + else + { + // Nothing to do. + } + } +} + +extension StateMachine: CustomStringConvertible +{ + public var description: String + { + return "State Machine: \(states.count) States | \(events.count) Events | Current State: \(current_state)" + } + + public var dot_description: String + { + var dot_graph = "digraph StateMachine {\n" + + dot_graph += " \"\" [style=\"invis\"]; \"\" -> \"\(initial_state.name)\" [dir=both, arrowtail=dot]; // Initial State\n" + dot_graph += " \"\(current_state.name)\" [style=bold]; // Current State\n" + + for (_, event) in events + { + for source in event.sources + { + dot_graph += " \"\(source.name)\" -> \"\(event.destination.name)\" [label=\"\(event.name)\", " + + "fontname=\"Menlo Italic\", fontsize=9];\n" + } + } + + dot_graph += "}" + + return dot_graph + } +} diff --git a/Sources/Krypton/State.swift b/Sources/Krypton/State.swift new file mode 100644 index 0000000..926e1a7 --- /dev/null +++ b/Sources/Krypton/State.swift @@ -0,0 +1,104 @@ +// +// State.swift +// Krypton +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +import Foundation + +public extension StateMachine +{ + struct State + { + public struct Context + { + private(set) var will_enter: TransitionContextAction? + private(set) var did_enter: TransitionContextAction? + private(set) var will_exit: TransitionContextAction? + private(set) var did_exit: TransitionContextAction? + + public init( + will_enter: TransitionContextAction? = nil, + did_enter: TransitionContextAction? = nil, + will_exit: TransitionContextAction? = nil, + did_exit: TransitionContextAction? = nil + ) + { + self.will_enter = will_enter + self.did_enter = did_enter + self.will_exit = will_exit + self.did_exit = did_exit + } + } + + let name: String + let user_info: Payload + let transition_context: Context? + + public init(name: String, user_info: Payload = [:], transition_context: Context? = nil) throws + { + guard !name.isEmpty + else + { + throw StateMachine.Error.invalid_state + } + + self.name = name + self.user_info = user_info + self.transition_context = transition_context + } + } +} + +extension StateMachine.State: Hashable +{ + public static func == (lhs: StateMachine.State, rhs: StateMachine.State) -> Bool + { + return lhs.name == rhs.name + } + + public func hash(into hasher: inout Hasher) + { + hasher.combine(name) + } +} + +extension StateMachine.State: Comparable +{ + public static func < (lhs: StateMachine.State, rhs: StateMachine.State) -> Bool + { + return lhs.name < rhs.name + } +} + +extension StateMachine.State: CustomStringConvertible +{ + public var description: String + { + return "\(name)" + } +} + +extension StateMachine.State: RawRepresentable +{ + public typealias RawValue = String + + public init?(rawValue: RawValue) + { + guard !rawValue.isEmpty + else + { + return nil + } + + name = rawValue + user_info = [:] + transition_context = Context() + } + + public var rawValue: String + { + return name + } +} diff --git a/Sources/Krypton/Transition.swift b/Sources/Krypton/Transition.swift new file mode 100644 index 0000000..c484755 --- /dev/null +++ b/Sources/Krypton/Transition.swift @@ -0,0 +1,32 @@ +// +// Transition.swift +// Krypton +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +import Foundation + +public extension StateMachine +{ + struct Transition + { + let event: Event + let source: State + let system: StateMachine + let user_info: Payload + + var destination: State + { + return event.destination + } + + init(event: Event, source: State, in system: StateMachine, user_info: Payload = [:]) + { + self.event = event + self.source = source + self.system = system + self.user_info = user_info + } + } +} diff --git a/Tests/KryptonTests/KryptonEventTests.swift b/Tests/KryptonTests/KryptonEventTests.swift new file mode 100644 index 0000000..b034b58 --- /dev/null +++ b/Tests/KryptonTests/KryptonEventTests.swift @@ -0,0 +1,122 @@ +// +// KryptonEventTests.swift +// KryptonKitTests +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +@testable import Krypton +import XCTest + +class KryptonEventTests: XCTestCase +{ + func testCreateAnEvent() throws + { + let state = try StateMachine.State(name: "State A") + let event = try StateMachine.Event(name: "Event A", sources: [state], destination: state) + + XCTAssertTrue(event.name == "Event A", "The name of the event should have been `Event A`, but it is `\(event.name)`") + } + + func testAnEventForEquality() throws + { + let state = try StateMachine.State(name: "State A") + let eventA = try StateMachine.Event(name: "Event A", sources: [state], destination: state) + let eventB = eventA + + XCTAssertTrue(eventA == eventB, "Both event should have been equal, but they are not.") + } + + func testAnEventHash() throws + { + let state = try StateMachine.State(name: "State A") + let event = try StateMachine.Event(name: "Event A", sources: [state], destination: state) + let setOfEvents: Set = [event, event] + + XCTAssertTrue(setOfEvents.count == 1, "There should have only been a single event, but there are more.") + } + + func testAnEventDescriptionWithOneSourceState() throws + { + let state = try StateMachine.State(name: "State A") + let event = try StateMachine.Event(name: "Event A", sources: [state], destination: state) + let expectedValue = "Triggered: Event `Event A` | transition: State A -> State A" + let actualValue = String(describing: event) + + XCTAssertTrue( + actualValue == expectedValue, + "The description, `\(actualValue)`, does not match what was expected, `\(expectedValue)`" + ) + } + + func testAnEventDescriptionWithMultipleSourceState() throws + { + let state = try StateMachine.State(name: "State A") + let stateB = try StateMachine.State(name: "State B") + let stateC = try StateMachine.State(name: "State C") + let event = try StateMachine.Event(name: "Event A", sources: [state, stateB, stateC], destination: state) + let expectedValue = "Triggered: Event `Event A` | transition: State A, State B, and State C, -> State A" + let actualValue = String(describing: event) + + XCTAssertTrue( + actualValue == expectedValue, + "The description, `\(actualValue)`, does not match what was expected, `\(expectedValue)`" + ) + } + + func testEventThatIsDeclined() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let disallow_event: StateMachine.Event.TransitionTriggerValidation = { _, _ -> Bool in false } + let event_transition = StateMachine.Event.TransitionContext( + should_fire: disallow_event, + will_fire: nil, + did_fire: nil + ) + let eventA = try StateMachine.Event( + name: "Event-A-to-B", + sources: [stateA], + destination: stateB, + transition_context: event_transition + ) + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(events: [eventA]) + system.activate() + + XCTAssertThrowsError( + try system.fire(event: eventA), + "We expected the `declined` error; but no errors were thrown." + ) + } + + func testEventThatIsExplicitlyAllowedToBeTriggered() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let allow_event: StateMachine.Event.TransitionTriggerValidation = { _, _ -> Bool in true } + let event_transition = StateMachine.Event.TransitionContext( + should_fire: allow_event, + will_fire: nil, + did_fire: nil + ) + let eventA = try StateMachine.Event( + name: "Event-A-to-B", + sources: [stateA], + destination: stateB, + transition_context: event_transition + ) + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(events: [eventA]) + system.activate() + + XCTAssertNoThrow( + try system.fire(event: eventA), + "We expected the state machine to allow the event, but it was declined." + ) + } +} diff --git a/Tests/KryptonTests/KryptonStateTests.swift b/Tests/KryptonTests/KryptonStateTests.swift new file mode 100644 index 0000000..3c38fae --- /dev/null +++ b/Tests/KryptonTests/KryptonStateTests.swift @@ -0,0 +1,40 @@ +// +// KryptonStateTests.swift +// KryptonTests +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +@testable import Krypton +import XCTest + +final class KryptonStateTests: XCTestCase +{ + func testCreatingStateSuccessfully() throws + { + XCTAssertNoThrow( + try StateMachine.State(name: "State-A"), + "We expected an state to be created; but it wasn't." + ) + } + + func testFailureToCreateAState() throws + { + XCTAssertThrowsError(try StateMachine.State(name: ""), "We expected the `invalidState` error to be rasied; but it was not.") + } + + func testCreatingStateUsingRawValue() throws + { + let state = StateMachine.State(rawValue: "State-A") + + XCTAssertNotNil(state, "We expected a non-nil state object; but received a `nil` object") + XCTAssertTrue(state?.rawValue == "State-A") + } + + func testFailureToCreateStateUsingRawValue() throws + { + let state = StateMachine.State(rawValue: "") + + XCTAssertNil(state, "We expected a nil state object; but received a non-nil object") + } +} diff --git a/Tests/KryptonTests/KryptonTests.swift b/Tests/KryptonTests/KryptonTests.swift new file mode 100644 index 0000000..9028d5a --- /dev/null +++ b/Tests/KryptonTests/KryptonTests.swift @@ -0,0 +1,284 @@ +// +// KryptonTests.swift +// KryptonTests +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +@testable import Krypton +import XCTest + +final class KryptonTests: XCTestCase +{ + func testCreateStatemachineWithTwoStates() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let event = try StateMachine.Event(name: "From-A-to-B", sources: [stateA], destination: stateB) + + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(event: event) + system.activate() + + XCTAssertTrue(system.states.count == 2, "We expected the state machine to have 2 states, but it has `\(system.states.count)` states.") + } + + func testStateMachineIsImmutableAfterActivation() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let event = try StateMachine.Event(name: "From-A-to-B", sources: [stateA], destination: stateB) + + let system = try StateMachine(initial_state: stateA) + + system.activate() + + system.add(states: [stateA, stateB]) + system.add(event: event) + system.add(events: [event]) + + XCTAssertTrue(system.states.isEmpty, "We expected to not have any states, but the state machine has states.") + } + + func testStateMachineWithSingleEvent() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let event = try StateMachine.Event(name: "From-A-to-B", sources: [stateA], destination: stateB) + + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(event: event) + system.activate() + + XCTAssertTrue(system.events.count == 1, "We expected the state machine to have 1 event, but it has `\(system.events.count)` events.") + } + + func testStateMachineByAddingSameStateTwice() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let event = try StateMachine.Event(name: "From-A-to-B", sources: [stateA], destination: stateB) + + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(state: stateA) + system.add(event: event) + system.activate() + + XCTAssertTrue(system.states.count == 2, "We expected the state machine to have 2 states, but it has `\(system.states.count)` states.") + } + + func testStateMachineByAddingSameEventTwice() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let event = try StateMachine.Event(name: "From-A-to-B", sources: [stateA], destination: stateB) + + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(event: event) + system.add(event: event) + system.activate() + + XCTAssertTrue(system.events.count == 1, "We expected the state machine to have 1 event, but it has `\(system.events.count)` events.") + } + + func testAddingMultipleEvents() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let eventA = try StateMachine.Event(name: "From-A-to-B", sources: [stateA], destination: stateB) + let eventB = try StateMachine.Event(name: "From-B-to-A", sources: [stateB], destination: stateA) + + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(events: [eventA, eventB]) + system.activate() + + XCTAssertTrue(system.events.count == 2, "We expected the state machine to have 2 events, but it has `\(system.events.count)` events.") + } + + func testAddingAStateAfterActivation() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + + let system = try StateMachine(initial_state: stateA) + + system.activate() + + system.add(state: stateB) + + XCTAssertTrue(system.states.isEmpty, "We expected to not have any states, but the state machine has states.") + } + + func testEventLookupPerformance() throws + { + var states: Set = [] + + for index in 0 ... 10000 + { + try states.insert(StateMachine.State(name: "State-\(index)")) + } + + let initialState = try StateMachine.State(name: "State-Initial") + let system = try StateMachine(initial_state: initialState) + + system.add(states: states) + system.activate() + + measure + { + _ = system.event(named: "State-4200") + } + } + + func testStateLookupPerformance() throws + { + var events: Set = [] + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + + for index in 0 ... 10000 + { + try events.insert(StateMachine.Event(name: "Event-\(index)", sources: [stateA, stateB], destination: stateB)) + } + + system.add(events: events) + system.activate() + + measure + { + _ = system.event(named: "Event-4200") + } + } + + func testFiringEventPerformance() throws + { + var events: Set = [] + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + + for index in 0 ... 10000 + { + try events.insert(StateMachine.Event(name: "Event-\(index)", sources: [stateA, stateB], destination: stateB)) + } + + system.add(events: events) + system.activate() + + let result = system.event(named: "Event-4200") + let fireEvent: StateMachine.Event + + if case .success(let event) = result + { + fireEvent = event + + measure + { + try? system.fire(event: fireEvent) + } + } + else + { + XCTFail("An event was not found.") + } + } + + func testStateLookupFailure() throws + { + let stateA = try StateMachine.State(name: "State-A") + let system = try StateMachine(initial_state: stateA) + let result = system.state(named: "State-B") + var value = false + + if case .failure(let expectedError) = result + { + if case .not_found = expectedError + { + value = true + } + else + { + // Nothing to do. + } + } + else + { + // Nothing to do. + } + + XCTAssertTrue( + value, + "We expected the state machine to return a `notActivated` error, but did not received any error." + ) + } + + func testStateMachineInCorrectState() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let eventA = try StateMachine.Event(name: "Event-A-to-B", sources: [stateA], destination: stateB) + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(events: [eventA]) + + system.activate() + + try system.fire(event: eventA) + + XCTAssertTrue( + system.isIn(state: stateB), + "We expected the state machine to be in state `\(stateB.name)`, but it is in `\(system.current_state.name)`." + ) + } + + func testStateMachineCannotFireEventWhenNotActivated() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let eventA = try StateMachine.Event(name: "Event-A-to-B", sources: [stateA], destination: stateB) + let system = try StateMachine(initial_state: stateA) + + system.add(states: [stateA, stateB]) + system.add(events: [eventA]) + + XCTAssertThrowsError( + try system.fire(event: eventA), + "We expected the `not_activated` error; but no errors were thrown." + ) + } + + func testTransitionIsNotPermitted() throws + { + let stateA = try StateMachine.State(name: "State-A") + let stateB = try StateMachine.State(name: "State-B") + let stateC = try StateMachine.State(name: "State-C") + let eventA = try StateMachine.Event(name: "Event-A-to-B", sources: [stateA], destination: stateB) + let system = try StateMachine(initial_state: stateC) + + system.add(states: [stateA, stateB, stateC]) + system.add(events: [eventA]) + + system.activate() + + XCTAssertThrowsError( + try system.fire(event: eventA), + "We expected the `cannot_fire` error; but no errors were thrown." + ) + } +} diff --git a/Tests/KryptonTests/XCTestManifests.swift b/Tests/KryptonTests/XCTestManifests.swift new file mode 100644 index 0000000..7bfaadd --- /dev/null +++ b/Tests/KryptonTests/XCTestManifests.swift @@ -0,0 +1,18 @@ +// +// XCTestManifests.swift +// KryptonTests +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// + +import XCTest + +#if !canImport(ObjectiveC) + public func allTests() -> [XCTestCaseEntry] + { + return [ + testCase(KryptonTests.allTests), + testCase(KryptonEventTests.allTests), + ] + } +#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..f3dcd02 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,13 @@ +// +// LinuxMain.swift +// KryptonTests +// +// Copyright © 2019-2023 Farhan Ahmed. All rights reserved. +// +import XCTest + +import KryptonTests + +var tests = [XCTestCaseEntry]() +tests += KryptonTests.allTests() +XCTMain(tests)