diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..a557714 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +tab_width = 4 +indent_size = 4 +max_line_length = 120 +insert_final_newline = true +trim_trailing_whitespace = true + +[{*.md,LICENSE}] +max_line_length = 80 +trim_trailing_whitespace = false + +[{*.yml,*.yaml,*.json,*.js,*.ts,.prettierrc,*.html,*.css,*.scss}] +indent_size = 2 +tab_width = 2 + +[{*.go}] +# Go uses tabs and gofmt emits them by default. +indent_style = tab + +[{Makefile,.gitmodules}] +# Makefiles and .gitmodules must (!) use tabs. +indent_style = tab diff --git a/.github/commitlint.config.js b/.github/commitlint.config.js new file mode 100755 index 0000000..ede70d0 --- /dev/null +++ b/.github/commitlint.config.js @@ -0,0 +1,34 @@ +module.exports = { + rules: { + 'body-leading-blank': [2, 'always'], + 'body-max-line-length': [2, 'always', 100], + 'header-max-length': [2, 'always', 100], + 'scope-case': [2, 'always', 'lower-case'], + 'subject-case': [ + 2, + 'never', + ['start-case', 'pascal-case', 'upper-case'], + ], + 'subject-empty': [2, 'never'], + 'subject-full-stop': [2, 'never', '.'], + 'type-enum': [ + 2, + 'always', + [ + 'chore', + 'build', + 'ci', + 'docs', + 'feat', + 'feat!', + 'fix', + 'perf', + 'refactor', + 'test' + ], + ], + 'type-case': [2, 'always', 'lower-case'], + 'type-empty': [2, 'never'], + 'signed-off-by': [2, 'always'] + } +}; diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100755 index 0000000..cd522b1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 + +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + + - package-ecosystem: "github-actions" + directory: "/.github/workflows" + schedule: + interval: "daily" diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml new file mode 100755 index 0000000..3f51811 --- /dev/null +++ b/.github/workflows/commit-lint.yml @@ -0,0 +1,25 @@ +name: Lint commit message + +on: + push: + branches: ["**"] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Clone + uses: actions/checkout@v4 + + - name: Setup node 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + check-latest: true + + - name: Install commitlint + run: npm install -g @commitlint/cli @commitlint/config-conventional + + - name: Lint commit message + run: echo "${{ github.event.head_commit.message }}" | commitlint --config .github/commitlint.config.js diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100755 index 0000000..ff6977d --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,60 @@ +name: Go Workflow (Push) + +on: + push: + branches: ["**"] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + check-latest: true + + - name: Build + run: go build ./... + + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + check-latest: true + + - name: Test + run: go test -json ./... + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + check-latest: true + + - name: Format + run: | + OUT="$(go fmt $(go list ./... | grep -v /vendor/) 2>&1)" + if [ -n "$OUT" ]; then + echo "The following files are not correctly formatted" + echo "${OUT}" + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100755 index 0000000..d090458 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Go Workflow (Release) + +on: + release: + types: [created] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + check-latest: true + + - name: Build + run: go build ./... + + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + check-latest: true + + - name: Test + run: go test -json ./... + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.23" + check-latest: true + + - name: Format + run: | + OUT="$(go fmt $(go list ./... | grep -v /vendor/) 2>&1)" + if [ -n "$OUT" ]; then + echo "The following files are not correctly formatted" + echo "${OUT}" + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa80c6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +.DS_Store +.idea/ +*.iml +.settings diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100755 index 0000000..a004694 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "streetsidesoftware.code-spell-checker", + "editorconfig.editorconfig", + "davidanson.vscode-markdownlint", + "sonarsource.sonarlint-vscode", + "wayou.vscode-todo-highlight", + "redhat.vscode-yaml", + "ms-vscode.makefile-tools", + "golang.go" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100755 index 0000000..c887834 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "cSpell.language": "en,de", + "cSpell.words": [ + "commitlint", + "github", + "gomod", + "yannickkirschen" + ], + "editor.formatOnSave": true, + "editor.wordWrap": "on", + "editor.rulers": [ + 80, + 120 + ] +} \ No newline at end of file diff --git a/.whitesource b/.whitesource new file mode 100755 index 0000000..189bfd6 --- /dev/null +++ b/.whitesource @@ -0,0 +1,8 @@ +{ + "checkRunSettings": { + "vulnerableCheckRunConclusionLevel": "failure" + }, + "issueSettings": { + "minSeverityLevel": "LOW" + } +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100755 index 0000000..abc7c35 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +Actually, the author is hungry and does not want to write a novel here. So, +please just act like a normal human and treat others the same way as you want to +be treated. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100755 index 0000000..719bfea --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,22 @@ +# Contributing + +When contributing to this repository, please first discuss the change in the +discussions. + +Please note we have a code of conduct, please follow it in all your interactions +with the project. + +## Coding + +1. Fork the repository. +2. Make your changes on a dedicated branch. +3. Commit your changes according + to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0). + Please try to only make one commit. Squash/rebase if you made a bunch of + fix-up-commits! Commits will be linted on every push. +4. Open up a pull request. +5. Merge the pull request and make a [release](#release) if required. + +## Release + +Ask the maintainer how to make a release. diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..68a49da --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +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 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. + +For more information, please refer to diff --git a/README.md b/README.md new file mode 100755 index 0000000..f07f98e --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# State Machine + +[![Lint commit message](https://github.com/yannickkirschen/state-machine/actions/workflows/commit-lint.yml/badge.svg)](https://github.com/yannickkirschen/state-machine/actions/workflows/commit-lint.yml) +[![Push](https://github.com/yannickkirschen/state-machine/actions/workflows/push.yml/badge.svg)](https://github.com/yannickkirschen/state-machine/actions/workflows/push.yml) +[![Release](https://github.com/yannickkirschen/state-machine/actions/workflows/release.yml/badge.svg)](https://github.com/yannickkirschen/state-machine/actions/workflows/release.yml) +[![GitHub release](https://img.shields.io/github/release/yannickkirschen/state-machine.svg)](https://github.com/yannickkirschen/state-machine/releases/) + +State Machine is a Golang library implementing a [finite-state machine](https://en.wikipedia.org/wiki/Finite-state_machine). + +## Usage + +Basic usage works as follows: + +1. Create a machine with an initial state. +2. Set all possible transitions between states. +3. Optional: Set enter end exit actions. +4. Transition from state to state ;) + +Here is an example code based on the very simple use-case of a door: + +```go +// Signature: +machine := fsm.NewMachine("close") + +// Signature: +machine.SetTransition("open", "close-door", "close") +machine.SetTransition("close", "open-door", "open") + +machine.SetEnterAction(func(last, new fsm.State) error { + fmt.Printf("Enter '%s' coming from '%s'\n", new, last) + return nil + }) + +machine.SetExitAction(func(current, next fsm.State) error { + fmt.Printf("Leaving '%s' going to '%s'\n", current, next) + return nil + }) + +// Signature: +if err := machine.Transition("open-door"); err != nil { + panic(err) +} +``` diff --git a/examples/main.go b/examples/main.go new file mode 100644 index 0000000..9653adc --- /dev/null +++ b/examples/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + + fsm "github.com/yannickkirschen/state-machine" +) + +func main() { + machine := fsm.NewMachine("close") + machine.SetTransition("open", "close-door", "close") + machine.SetTransition("close", "open-door", "open") + + machine.SetEnterAction(func(last, new fsm.State) error { + fmt.Printf("Enter '%s' coming from '%s'\n", new, last) + return nil + }) + + machine.SetExitAction(func(current, next fsm.State) error { + fmt.Printf("Leaving '%s' going to '%s'\n", current, next) + return nil + }) + + if err := machine.Transition("open-door"); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..a12046e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/yannickkirschen/state-machine + +go 1.23 diff --git a/go.sum b/go.sum new file mode 100755 index 0000000..e69de29 diff --git a/state-machine.go b/state-machine.go new file mode 100644 index 0000000..d19e75d --- /dev/null +++ b/state-machine.go @@ -0,0 +1,63 @@ +package fsm + +import "fmt" + +type State string +type Event string + +type StateActionFunc func(State, State) error + +type TransitionEvent struct { + Current State + Input Event +} + +type Machine struct { + transitions map[TransitionEvent]State + current State + + enterAction StateActionFunc + exitAction StateActionFunc +} + +func NewMachine(initial State) *Machine { + return &Machine{ + transitions: map[TransitionEvent]State{}, + current: initial, + } +} + +func (machine *Machine) SetTransition(current State, input Event, next State) { + machine.transitions[TransitionEvent{Current: current, Input: input}] = next +} + +func (machine *Machine) SetEnterAction(f StateActionFunc) { + machine.enterAction = f +} + +func (machine *Machine) SetExitAction(f StateActionFunc) { + machine.exitAction = f +} + +func (machine *Machine) Transition(input Event) error { + next, ok := machine.transitions[TransitionEvent{Current: machine.current, Input: input}] + if !ok { + return fmt.Errorf("there is no state to transition to from state '%s' on event '%s'", machine.current, input) + } + + if machine.exitAction != nil { + if err := machine.exitAction(machine.current, next); err != nil { + return err + } + } + + if machine.enterAction != nil { + if err := machine.enterAction(machine.current, next); err != nil { + return err + } + } + + // We will only enter the next state if the enter action was successful. + machine.current = next + return nil +} diff --git a/state-machine_test.go b/state-machine_test.go new file mode 100644 index 0000000..5cf1ab4 --- /dev/null +++ b/state-machine_test.go @@ -0,0 +1,36 @@ +package fsm + +import ( + "fmt" + "testing" +) + +func getMachine() *Machine { + machine := NewMachine("close") + machine.SetTransition("open", "close-door", "close") + machine.SetTransition("close", "open-door", "open") + + machine.SetEnterAction(func(last, new State) error { + fmt.Printf("Enter '%s' coming from '%s'\n", new, last) + return nil + }) + + machine.SetExitAction(func(current, next State) error { + fmt.Printf("Leaving '%s' going to '%s'\n", current, next) + return nil + }) + + return machine +} + +func TestTransition(t *testing.T) { + machine := getMachine() + + if err := machine.Transition("open-door"); err != nil { + t.Error(err) + } + + if err := machine.Transition("open-door"); err == nil { + t.Error("expecting door to be opened") + } +}