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")
+ }
+}