Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
b7a4085
Part 1, generic Events
majst01 Jul 6, 2022
752ee38
Refactor a bit
majst01 Jul 7, 2022
21ed7a8
Generic state and Event
majst01 Jul 7, 2022
d00aa3c
Adopt examples
majst01 Jul 7, 2022
114e252
any instead of interface{}
majst01 Jul 7, 2022
b3de70b
gitignore
majst01 Jul 7, 2022
698f85d
Flows
majst01 Jul 7, 2022
4be1559
Naming
majst01 Jul 7, 2022
4e23d6f
One more
majst01 Jul 7, 2022
459416d
One more
majst01 Jul 7, 2022
80bc866
renaming again
majst01 Jul 7, 2022
978d788
Add benchmarks
majst01 Jul 7, 2022
49ce321
fix example
majst01 Jul 7, 2022
c62ba1b
Even more renamings
majst01 Jul 7, 2022
aa1a0dc
Generic Callbacks
majst01 Jul 9, 2022
eec2205
more generic event and state constraints, godoc
majst01 Jul 9, 2022
67d99fb
Fix examples
majst01 Jul 9, 2022
2cedcae
Fix
majst01 Jul 9, 2022
c8078b8
Fix readme
majst01 Jul 9, 2022
1e23f92
Linter
majst01 Jul 9, 2022
31b8183
nameing
majst01 Jul 10, 2022
3cc3d2c
nameing
majst01 Jul 11, 2022
cd3c5e7
Back to one alloc
majst01 Jul 11, 2022
cf20b0a
test metadata
majst01 Jul 11, 2022
b213cec
godoc
majst01 Jul 11, 2022
040175d
no implicit structs
majst01 Jul 11, 2022
28f6666
Add one more benchmark
majst01 Jul 11, 2022
bb1ee6b
Enable benchmarks in test
majst01 Jul 11, 2022
0b31398
Run tests with make target
majst01 Jul 11, 2022
1633e53
Named callbacktypes with validation
majst01 Jul 11, 2022
54758d4
Even more unsupported callbacks
majst01 Jul 11, 2022
fe6eccb
Add missing file
majst01 Jul 11, 2022
2925185
V2
majst01 Jul 13, 2022
a09c9e3
Updates
majst01 Sep 1, 2025
768342a
typos
majst01 Sep 1, 2025
186f4a4
typos
majst01 Sep 1, 2025
1d1f3c4
Update actions
majst01 Sep 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v5

- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v5
with:
go-version: 1.16
go-version: "1.24.x"

- name: Test
run: go test -coverprofile=coverage.out ./...
run: make test

- name: Convert coverage
uses: jandelgado/gcov2lcov-action@v1.0.5
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ _testmain.go

# Testing
.coverprofile
coverage.out

.vscode
.vscode
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ default: services test

.PHONY: test
test:
go test ./...
CGO_ENABLED=1 go test -benchmem -bench=. -v ./... -race -coverprofile=coverage.out -covermode=atomic && go tool cover -func=coverage.out

.PHONY: lint
lint:
Expand Down
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[![PkgGoDev](https://pkg.go.dev/badge/github.com/looplab/fsm)](https://pkg.go.dev/github.com/looplab/fsm)
![Bulid Status](https://github.com/looplab/fsm/actions/workflows/main.yml/badge.svg)
![Build Status](https://github.com/looplab/fsm/actions/workflows/main.yml/badge.svg)
[![Coverage Status](https://img.shields.io/coveralls/looplab/fsm.svg)](https://coveralls.io/r/looplab/fsm)
[![Go Report Card](https://goreportcard.com/badge/looplab/fsm)](https://goreportcard.com/report/looplab/fsm)

Expand All @@ -24,17 +24,17 @@ package main

import (
"fmt"
"github.com/looplab/fsm"
"github.com/looplab/fsm/v2"
)

func main() {
fsm := fsm.NewFSM(
fsm := fsm.New[string, string](
"closed",
fsm.Events{
fsm.Events[string, string]{
{Name: "open", Src: []string{"closed"}, Dst: "open"},
{Name: "close", Src: []string{"open"}, Dst: "closed"},
},
fsm.Callbacks{},
fsm.Callbacks[string, string]{},
)

fmt.Println(fsm.Current())
Expand Down Expand Up @@ -64,7 +64,7 @@ package main

import (
"fmt"
"github.com/looplab/fsm"
"github.com/looplab/fsm/v2"
)

type Door struct {
Expand All @@ -77,14 +77,19 @@ func NewDoor(to string) *Door {
To: to,
}

d.FSM = fsm.NewFSM(
d.FSM = fsm.New[string, string](
"closed",
fsm.Events{
fsm.Events[string, string]{
{Name: "open", Src: []string{"closed"}, Dst: "open"},
{Name: "close", Src: []string{"open"}, Dst: "closed"},
},
fsm.Callbacks{
"enter_state": func(e *fsm.Event) { d.enterState(e) },
fsm.Callbacks[string, string]{
fsm.Callback[string, string]{
When: fsm.AfterAllStates,
F: func(cr *fsm.CallbackContext[MyEvent, MyState]) {
d.enterState(e)
},
},
},
)

Expand Down
150 changes: 150 additions & 0 deletions callback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) 2013 - Max Persson <max@looplab.se>
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package fsm

import (
"cmp"
"fmt"
)

// CallbackType defines at which type of Event this callback should be called.
type CallbackType string

const (
// BeforeEvent called before event E
BeforeEvent = CallbackType("before_event")
// BeforeAllEvents called before all events
BeforeAllEvents = CallbackType("before_all_events")
// AfterEvent called after event E
AfterEvent = CallbackType("after_event")
// AfterAllEvents called after all events
AfterAllEvents = CallbackType("after_all_events")
// EnterState called after entering state S
EnterState = CallbackType("enter_state")
// EnterAllStates called after entering all states
EnterAllStates = CallbackType("enter_all_states")
// LeaveState is called before leaving state S.
LeaveState = CallbackType("leave_state")
// LeaveAllStates is called before leaving all states.
LeaveAllStates = CallbackType("leave_all_states")
)

// Callback defines a condition when the callback function F should be called in certain conditions.
// The order of execution for CallbackTypes in the same event or state is:
// The concrete CallbackType has precedence over a general one, e.g.
// BeforeEvent E will be fired before BeforeAllEvents.
type Callback[E cmp.Ordered, S cmp.Ordered] struct {
// When should the callback be called.
When CallbackType
// Event is the event that the callback should be called for. Only relevant for BeforeEvent and AfterEvent.
Event E
// State is the state that the callback should be called for. Only relevant for EnterState and LeaveState.
State S
// F is the callback function.
F func(*CallbackContext[E, S])
}

// Callbacks is a shorthand for defining the callbacks in New.
type Callbacks[E cmp.Ordered, S cmp.Ordered] []Callback[E, S]

// CallbackContext is the info that get passed as a reference in the callbacks.
type CallbackContext[E cmp.Ordered, S cmp.Ordered] struct {
// FSM is an reference to the current FSM.
FSM *FSM[E, S]
// Event is the event name.
Event E
// Src is the state before the transition.
Src S
// Dst is the state after the transition.
Dst S
// Err is an optional error that can be returned from a callback.
Err error
// Args is an optional list of arguments passed to the callback.
Args []any
// canceled is an internal flag set if the transition is canceled.
canceled bool
// async is an internal flag set if the transition should be asynchronous
async bool
}

// Cancel can be called in before_<EVENT> or leave_<STATE> to cancel the
// current transition before it happens. It takes an optional error, which will
// overwrite e.Err if set before.
func (ctx *CallbackContext[E, S]) Cancel(err ...error) {
ctx.canceled = true

if len(err) > 0 {
ctx.Err = err[0]
}
}

// Async can be called in leave_<STATE> to do an asynchronous state transition.
//
// The current state transition will be on hold in the old state until a final
// call to Transition is made. This will complete the transition and possibly
// call the other callbacks.
func (ctx *CallbackContext[E, S]) Async() {
ctx.async = true
}
func (cs Callbacks[E, S]) validate() error {
for i := range cs {
cb := cs[i]
err := cb.validate()
if err != nil {
return err
}
}
return nil
}

func (c *Callback[E, S]) validate() error {
var (
zeroEvent E
zeroState S
)
switch c.When {
case BeforeEvent, AfterEvent:
if c.Event == zeroEvent {
return fmt.Errorf("%v given but no event", c.When)
}
if c.State != zeroState {
return fmt.Errorf("%v given but state %v specified", c.When, c.State)
}
case BeforeAllEvents, AfterAllEvents:
if c.Event != zeroEvent {
return fmt.Errorf("%v given with event %v", c.When, c.Event)
}
if c.State != zeroState {
return fmt.Errorf("%v given with state %v", c.When, c.State)
}
case EnterState, LeaveState:
if c.State == zeroState {
return fmt.Errorf("%v given but no state", c.When)
}
if c.Event != zeroEvent {
return fmt.Errorf("%v given but event %v specified", c.When, c.Event)
}
case EnterAllStates, LeaveAllStates:
if c.State != zeroState {
return fmt.Errorf("%v given with state %v", c.When, c.State)
}
if c.Event != zeroEvent {
return fmt.Errorf("%v given with event %v", c.When, c.Event)
}
default:
return fmt.Errorf("invalid callback:%v", c)
}
return nil
}
62 changes: 62 additions & 0 deletions callback_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package fsm

import "testing"

func TestCallbackValidate(t *testing.T) {
tests := []struct {
name string
cb Callback[string, string]
errString string
}{
{
name: "before_event without event",
cb: Callback[string, string]{When: BeforeEvent},
errString: "before_event given but no event",
},
{
name: "before_event with state",
cb: Callback[string, string]{When: BeforeEvent, Event: "open", State: "closed"},
errString: "before_event given but state closed specified",
},
{
name: "before_event with state",
cb: Callback[string, string]{When: BeforeAllEvents, Event: "open"},
errString: "before_all_events given with event open",
},

{
name: "before_event without event",
cb: Callback[string, string]{When: EnterState},
errString: "enter_state given but no state",
},
{
name: "before_event with state",
cb: Callback[string, string]{When: EnterState, Event: "open", State: "closed"},
errString: "enter_state given but event open specified",
},
{
name: "before_event with state",
cb: Callback[string, string]{When: EnterAllStates, State: "closed"},
errString: "enter_all_states given with state closed",
},
}

for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
err := tt.cb.validate()

if tt.errString == "" && err != nil {
t.Errorf("err:%v", err)
}
if tt.errString != "" && err == nil {
t.Errorf("errstring:%s but err is nil", tt.errString)
}

if tt.errString != "" && err.Error() != tt.errString {
t.Errorf("transition failed %v", err)
}
})
}

}
25 changes: 15 additions & 10 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@

package fsm

import (
"cmp"
"fmt"
)

// InvalidEventError is returned by FSM.Event() when the event cannot be called
// in the current state.
type InvalidEventError struct {
Event string
State string
type InvalidEventError[E cmp.Ordered, S cmp.Ordered] struct {
Event E
State S
}

func (e InvalidEventError) Error() string {
return "event " + e.Event + " inappropriate in current state " + e.State
func (e InvalidEventError[E, S]) Error() string {
return fmt.Sprintf("event %v inappropriate in current state %v", e.Event, e.State)
}

// UnknownEventError is returned by FSM.Event() when the event is not defined.
Expand All @@ -31,7 +36,7 @@ type UnknownEventError struct {
}

func (e UnknownEventError) Error() string {
return "event " + e.Event + " does not exist"
return fmt.Sprintf("event %s does not exist", e.Event)
}

// InTransitionError is returned by FSM.Event() when an asynchronous transition
Expand All @@ -41,7 +46,7 @@ type InTransitionError struct {
}

func (e InTransitionError) Error() string {
return "event " + e.Event + " inappropriate because previous transition did not complete"
return fmt.Sprintf("event %s inappropriate because previous transition did not complete", e.Event)
}

// NotInTransitionError is returned by FSM.Transition() when an asynchronous
Expand All @@ -60,7 +65,7 @@ type NoTransitionError struct {

func (e NoTransitionError) Error() string {
if e.Err != nil {
return "no transition with error: " + e.Err.Error()
return fmt.Sprintf("no transition with error: %s", e.Err.Error())
}
return "no transition"
}
Expand All @@ -73,7 +78,7 @@ type CanceledError struct {

func (e CanceledError) Error() string {
if e.Err != nil {
return "transition canceled with error: " + e.Err.Error()
return fmt.Sprintf("transition canceled with error: %s", e.Err.Error())
}
return "transition canceled"
}
Expand All @@ -86,7 +91,7 @@ type AsyncError struct {

func (e AsyncError) Error() string {
if e.Err != nil {
return "async started with error: " + e.Err.Error()
return fmt.Sprintf("async started with error: %s", e.Err.Error())
}
return "async started"
}
Expand Down
2 changes: 1 addition & 1 deletion errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
func TestInvalidEventError(t *testing.T) {
event := "invalid event"
state := "state"
e := InvalidEventError{Event: event, State: state}
e := InvalidEventError[string, string]{Event: event, State: state}
if e.Error() != "event "+e.Event+" inappropriate in current state "+e.State {
t.Error("InvalidEventError string mismatch")
}
Expand Down
Loading