diff --git a/README.md b/README.md index 670c352..d98f12b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,31 @@ f.Transition( ) ``` +_Visual simple event_: +```mermaid +flowchart LR + id0(StateBar) + id1(StateFoo) + id1--> |EventFoo| id0 +``` + +Transitions can be triggered the second time an event occurs: + +```go +f.Transition( + fsm.On(EventFoo), fsm.Src(StateFoo), fsm.Times(2), + fsm.Dst(StateBar), +) +``` + +_Visual repeated event_: +```mermaid +flowchart LR + id0(StateBar) + id1(StateFoo) + id1--> |2 x EventFoo| id0 +``` + You can have custom checks or actions: ```go @@ -35,15 +60,6 @@ f.Transition( ) ``` -Transitions can be triggered the second time an event occurs: - -```go -f.Transition( - fsm.On(EventFoo), fsm.Src(StateFoo), fsm.Times(2), - fsm.Dst(StateBar), -) -``` - Functions can be called when entering or leaving a state: ```go diff --git a/cmd/doc.go b/cmd/doc.go new file mode 100644 index 0000000..ca90c2f --- /dev/null +++ b/cmd/doc.go @@ -0,0 +1,68 @@ +//go:generate stringer -type=State,Event --output=doc_fsm_string.go +//go:generate go run -tags doc doc.go doc_fsm_string.go ../README.md + +package main + +import ( + "fmt" + "os" + + "github.com/cocoonspace/fsm" +) + +type State fsm.State +type Event fsm.Event + +func (s State) State() fsm.State { + return fsm.State(s) +} + +func (e Event) Event() fsm.Event { + return fsm.Event(e) +} + +var _ fsm.NamedState = (*State)(nil) +var _ fsm.NamedEvent = (*Event)(nil) + +const ( + StateFoo State = iota + StateBar +) + +const ( + EventFoo Event = iota +) + +func example1() *fsm.FSM { + f := fsm.New(StateFoo.State()) + + f.Transition( + fsm.On(EventFoo), + fsm.Src(StateFoo), + fsm.Dst(StateBar), + ) + + return f +} + +func example2() *fsm.FSM { + f := fsm.New(StateFoo.State()) + + f.Transition( + fsm.On(EventFoo), + fsm.Times(2), + fsm.Src(StateFoo), + fsm.Dst(StateBar), + ) + + return f +} + +func main() { + if len(os.Args) != 2 { + fmt.Println("use go generate") + return + } + example1().GenerateDoc("Visual simple event", os.Args[1]) + example2().GenerateDoc("Visual repeated event", os.Args[1]) +} diff --git a/cmd/doc_fsm_string.go b/cmd/doc_fsm_string.go new file mode 100644 index 0000000..b34dec9 --- /dev/null +++ b/cmd/doc_fsm_string.go @@ -0,0 +1,41 @@ +// Code generated by "stringer -type=State,Event --output=doc_fsm_string.go"; DO NOT EDIT. + +package main + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[StateFoo-0] + _ = x[StateBar-1] +} + +const _State_name = "StateFooStateBar" + +var _State_index = [...]uint8{0, 8, 16} + +func (i State) String() string { + if i < 0 || i >= State(len(_State_index)-1) { + return "State(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _State_name[_State_index[i]:_State_index[i+1]] +} +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[EventFoo-0] +} + +const _Event_name = "EventFoo" + +var _Event_index = [...]uint8{0, 8} + +func (i Event) String() string { + if i < 0 || i >= Event(len(_Event_index)-1) { + return "Event(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Event_name[_Event_index[i]:_Event_index[i+1]] +} diff --git a/doc.go b/doc.go deleted file mode 100644 index e4cda39..0000000 --- a/doc.go +++ /dev/null @@ -1,54 +0,0 @@ -/* -Package fsm allows you to add Finite State Machines to your code. - - const ( - StateFoo fsm.State = iota - StateBar - ) - - const ( - EventFoo fsm.Event = iota - ) - - f := fsm.New(StateFoo) - f.Transition( - fsm.On(EventFoo), fsm.Src(StateFoo), - fsm.Dst(StateBar), - ) - -You can have custom checks or actions: - - f.Transition( - fsm.Src(StateFoo), fsm.Check(func() bool { - // check something - }), - fsm.Call(func() { - // do something - }), - ) - - -Transitions can be triggered the second time an event occurs: - - f.Transition( - fsm.On(EventFoo), fsm.Src(StateFoo), fsm.Times(2), - fsm.Dst(StateBar), - ) - -Functions can be called when entering or leaving a state: - - f.EnterState(StateFoo, func() { - // do something - }) - f.Enter(func(state fsm.State) { - // do something - }) - f.ExitState(StateFoo, func() { - // do something - }) - f.Exit(func(state fsm.State) { - // do something - }) - -*/ -package fsm diff --git a/fsm.go b/fsm.go index ad130e7..b243cb0 100644 --- a/fsm.go +++ b/fsm.go @@ -1,7 +1,13 @@ package fsm +import ( + "fmt" + "strconv" +) + // Event is the event type. // You can define your own values as +// // const ( // EventFoo fsm.Event = iota // EventBar @@ -10,17 +16,28 @@ type Event int // State is the state type. // You can define your own values as +// // const ( // StateFoo fsm.State = iota // StateBar // ) type State int -type transition struct { - conditions []optionCondition - actions []optionAction +// NamedState allow for pretty printing the FSM state by providing a String() interface +type NamedState interface { + State() State + fmt.Stringer +} + +// NamedEvent allow for pretty printing the FSM event by providing a String() interface +type NamedEvent interface { + Event() Event + fmt.Stringer } +var _ NamedState = (*State)(nil) +var _ NamedEvent = (*Event)(nil) + func (t *transition) match(e Event, times int, fsm *FSM) result { var res result for _, fn := range t.conditions { @@ -55,12 +72,12 @@ type FSM struct { } // New creates a new finite state machine having the specified initial state. -func New(initial State) *FSM { +func New(initial NamedState) *FSM { return &FSM{ enterState: map[State]func(){}, exitState: map[State]func(){}, - current: initial, - initial: initial, + current: initial.State(), + initial: initial.State(), } } @@ -88,12 +105,11 @@ func (f *FSM) Transition(opts ...Option) { f.transitions = append(f.transitions, t) } -// Src defines the source States for a Transition. -func Src(s ...State) Option { +func srcInternal(s ...NamedState) Option { return func(t *transition) { t.conditions = append(t.conditions, func(e Event, times int, fsm *FSM) result { for _, src := range s { - if fsm.current == src { + if fsm.current == src.State() { return resultOK } } @@ -102,11 +118,10 @@ func Src(s ...State) Option { } } -// On defines the Event that triggers a Transition. -func On(e Event) Option { +func onInternal(e NamedEvent) Option { return func(t *transition) { t.conditions = append(t.conditions, func(evt Event, times int, fsm *FSM) result { - if e == evt { + if e.Event() == evt { return resultOK } return resultNOK @@ -114,8 +129,7 @@ func On(e Event) Option { } } -// Dst defines the new State the machine switches to after a Transition. -func Dst(s State) Option { +func dstInternal(s NamedState) Option { return func(t *transition) { t.actions = append(t.actions, func(fsm *FSM) { if fsm.current == s { @@ -127,7 +141,7 @@ func Dst(s State) Option { if fsm.exit != nil { fsm.exit(fsm.current) } - fsm.current = s + fsm.current = s.State() if fn, ok := fsm.enterState[fsm.current]; ok { fn() } @@ -138,8 +152,7 @@ func Dst(s State) Option { } } -// NotCheck is an external condition that allows a Transition only if fn returns false. -func NotCheck(fn func() bool) Option { +func notCheckInternal(fn func() bool) Option { return func(t *transition) { t.conditions = append(t.conditions, func(e Event, times int, fsm *FSM) result { if !fn() { @@ -150,8 +163,7 @@ func NotCheck(fn func() bool) Option { } } -// Check is an external condition that allows a Transition only if fn returns true. -func Check(fn func() bool) Option { +func checkInternal(fn func() bool) Option { return func(t *transition) { t.conditions = append(t.conditions, func(e Event, times int, fsm *FSM) result { if fn() { @@ -162,8 +174,7 @@ func Check(fn func() bool) Option { } } -// Call defines a function that is called when a Transition occurs. -func Call(fn func()) Option { +func callInternal(fn func()) Option { return func(t *transition) { t.actions = append(t.actions, func(fsm *FSM) { fn() @@ -171,9 +182,7 @@ func Call(fn func()) Option { } } -// Times defines the number of consecutive times conditions must be valid before a Transition occurs. -// Times will not work if multiple Transitions are possible at the same time. -func Times(n int) Option { +func timesInternal(n int) Option { return func(t *transition) { t.conditions = append(t.conditions, func(e Event, times int, fsm *FSM) result { if times == n { @@ -207,25 +216,23 @@ func (f *FSM) Exit(fn func(state State)) { f.exit = fn } -// EnterState sets a func that will be called when entering a specific state. -func (f *FSM) EnterState(state State, fn func()) { - f.enterState[state] = fn +func (f *FSM) enterStateInternal(state NamedState, fn func()) { + f.enterState[state.State()] = fn } -// ExitState sets a func that will be called when exiting a specific state. -func (f *FSM) ExitState(state State, fn func()) { - f.exitState[state] = fn +func (f *FSM) exitStateInternal(state NamedState, fn func()) { + f.exitState[state.State()] = fn } // Event send an Event to a machine, applying at most one transition. // true is returned if a transition has been applied, false otherwise. -func (f *FSM) Event(e Event) bool { +func (f *FSM) Event(e NamedEvent) bool { for i := range f.transitions { times := f.times if i != f.previous { times = 0 } - if res := f.transitions[i].match(e, times+1, f); res != resultNOK { + if res := f.transitions[i].match(e.Event(), times+1, f); res != resultNOK { if res == resultOK { f.transitions[i].apply(f) } @@ -240,3 +247,30 @@ func (f *FSM) Event(e Event) bool { } return false } + +var documentationPathToIgnore []string + +// AddDocumentationPathToIgnore add base path to ignore when displaying file path in generated documentation +func AddDocumentationPathToIgnore(path string) { + documentationPathToIgnore = append(documentationPathToIgnore, path) +} + +// State return the value of a State to be compliant with NamedState +func (f State) State() State { + return f +} + +// String return the value as a string of a State to be compliant with NamedState +func (f State) String() string { + return strconv.Itoa(int(f)) +} + +// Event return the value of an Event to be compliant with NamedEvent +func (e Event) Event() Event { + return e +} + +// String return the value as a string of an Event to be compliant with NamedEvent +func (e Event) String() string { + return strconv.Itoa(int(e)) +} diff --git a/fsm_doc.go b/fsm_doc.go new file mode 100644 index 0000000..962a7d0 --- /dev/null +++ b/fsm_doc.go @@ -0,0 +1,256 @@ +//go:build doc +// +build doc + +package fsm + +import ( + "bufio" + "bytes" + "fmt" + "io" + "io/ioutil" + "os" + "runtime" + "strings" +) + +type transition struct { + conditions []optionCondition + actions []optionAction + + srcs []string + on string + dst string + calls []string + times int +} + +// Src defines the source States for a Transition. +func Src(s ...NamedState) Option { + return func(t *transition) { + srcInternal(s...)(t) + + for _, src := range s { + t.srcs = append(t.srcs, fmt.Sprintf("%v", src)) + } + } +} + +// On defines the Event that triggers a Transition. +func On(e NamedEvent) Option { + return func(t *transition) { + onInternal(e)(t) + + t.on = fmt.Sprintf("%v", e) + } +} + +// Dst defines the new State the machine switches to after a Transition. +func Dst(s NamedState) Option { + return func(t *transition) { + dstInternal(s)(t) + + t.dst = fmt.Sprintf("%v", s) + } +} + +// NotCheck is an external condition that allows a Transition only if fn returns false. +func NotCheck(fn func() bool) Option { + return notCheckInternal(fn) +} + +// Check is an external condition that allows a Transition only if fn returns true. +func Check(fn func() bool) Option { + return checkInternal(fn) +} + +// Call defines a function that is called when a Transition occurs. +func Call(fn func()) Option { + _, file, line, _ := runtime.Caller(1) + + return func(t *transition) { + callInternal(fn)(t) + + t.calls = append(t.calls, fmt.Sprintf("%s:%d", file, line)) + } +} + +// Times defines the number of consecutive times conditions must be valid before a Transition occurs. +// Times will not work if multiple Transitions are possible at the same time. +func Times(n int) Option { + return func(t *transition) { + timesInternal(n) + + t.times = n + } +} + +// EnterState sets a func that will be called when entering a specific state. +func (f *FSM) EnterState(state NamedState, fn func()) { + f.enterStateInternal(state, fn) +} + +// ExitState sets a func that will be called when exiting a specific state. +func (f *FSM) ExitState(state NamedState, fn func()) { + f.exitStateInternal(state, fn) +} + +// GenerateDoc will find if it exist the mermaid block in the markdown file with the right title +// and update it with the content describing this FSM. If it can not find the mermaid block, it will +// append it at the end of the file. The FSM package need to be compile with the tag `doc` for this +// to work. +func (f *FSM) GenerateDoc(title string, file string) error { + lookupTitle := []byte("_" + title + "_:") + + generated, err := f.insertMermaidGraphInPlace(lookupTitle, file) + if err == nil { + os.Remove(file) + return os.Rename(generated, file) + } + + if !os.IsNotExist(err) { + return err + } + + c, err := os.Create(file) + if err != nil { + return err + } + defer c.Close() + + w := bufio.NewWriter(c) + defer w.Flush() + + f.insertMermaidBlock(lookupTitle, w) + + return nil +} + +var uniqueNameCounter int + +func uniqueName(w *bufio.Writer, state string, uniqueNameMapping map[string]string) string { + unique, ok := uniqueNameMapping[state] + if ok { + return unique + } + + // Generate a unique ID for this state + unique = fmt.Sprintf("id%d", uniqueNameCounter) + uniqueNameCounter++ + w.WriteString("\t" + unique + "(" + state + ")\n") + uniqueNameMapping[state] = unique + + return unique +} + +var ( + lookupMermaid = []byte("```mermaid") + lookupEnd = []byte("```") +) + +func (f *FSM) insertMermaidGraph(w *bufio.Writer) { + uniqueNameCounter = 0 + uniqueNameMapping := make(map[string]string) + + w.WriteString("flowchart LR\n") + + for _, t := range f.transitions { + dstID := uniqueName(w, t.dst, uniqueNameMapping) + + if t.on == "" { + continue + } + on := t.on + if t.times > 1 { + on = fmt.Sprintf("%d x %s", t.times, on) + } + for _, call := range t.calls { + prettyCall := call + for _, path := range documentationPathToIgnore { + if strings.HasPrefix(prettyCall, path) { + prettyCall = prettyCall[len(path):] + break + } + } + on = on + "
" + prettyCall + } + + for _, src := range t.srcs { + srcID := uniqueName(w, src, uniqueNameMapping) + + w.WriteString("\t" + srcID + "--> |" + on + "| " + dstID + "\n") + } + } +} + +func (f *FSM) insertMermaidBlock(lookupTitle []byte, w *bufio.Writer) { + w.Write(lookupTitle) + w.WriteString("\n") + w.Write(lookupMermaid) + w.WriteString("\n") + + f.insertMermaidGraph(w) + + w.Write(lookupEnd) + w.WriteString("\n") +} + +func (f *FSM) insertMermaidGraphInPlace(lookupTitle []byte, file string) (string, error) { + out, err := ioutil.TempFile(".", "fsm-doc-") + if err != nil { + return "", err + } + defer out.Close() + + w := bufio.NewWriter(out) + defer w.Flush() + + in, err := os.Open(file) + if err != nil { + os.Remove(out.Name()) + return "", err + } + defer in.Close() + + mermaidNext := false + searchEnd := false + found := false + + reader := bufio.NewReader(in) + for { + line, _, err := reader.ReadLine() + + if err == io.EOF { + break + } else if err != nil { + return "", err + } + + if bytes.Equal(line, lookupTitle) { + mermaidNext = true + } else if mermaidNext && bytes.Equal(line, lookupMermaid) { + mermaidNext = false + searchEnd = true + found = true + + w.Write(lookupMermaid) + w.WriteString("\n") + } else if searchEnd && bytes.Equal(line, lookupEnd) { + f.insertMermaidGraph(w) + searchEnd = false + } else { + mermaidNext = false + } + + if !searchEnd { + w.Write(line) + w.WriteString("\n") + } + } + + if !found { + f.insertMermaidBlock(lookupTitle, w) + } + + return out.Name(), nil +} diff --git a/fsm_nodoc.go b/fsm_nodoc.go new file mode 100644 index 0000000..3552bb0 --- /dev/null +++ b/fsm_nodoc.go @@ -0,0 +1,65 @@ +//go:build !doc +// +build !doc + +package fsm + +import "errors" + +type transition struct { + conditions []optionCondition + actions []optionAction +} + +// Src defines the source States for a Transition. +func Src(s ...NamedState) Option { + return srcInternal(s...) +} + +// On defines the Event that triggers a Transition. +func On(e NamedEvent) Option { + return onInternal(e) +} + +// Dst defines the new State the machine switches to after a Transition. +func Dst(s NamedState) Option { + return dstInternal(s) +} + +// NotCheck is an external condition that allows a Transition only if fn returns false. +func NotCheck(fn func() bool) Option { + return notCheckInternal(fn) +} + +// Check is an external condition that allows a Transition only if fn returns true. +func Check(fn func() bool) Option { + return checkInternal(fn) +} + +// Call defines a function that is called when a Transition occurs. +func Call(fn func()) Option { + return callInternal(fn) +} + +// Times defines the number of consecutive times conditions must be valid before a Transition occurs. +// Times will not work if multiple Transitions are possible at the same time. +func Times(n int) Option { + return timesInternal(n) +} + +// EnterState sets a func that will be called when entering a specific state. +func (f *FSM) EnterState(state NamedState, fn func()) { + f.enterStateInternal(state, fn) +} + +// ExitState sets a func that will be called when exiting a specific state. +func (f *FSM) ExitState(state NamedState, fn func()) { + f.exitStateInternal(state, fn) +} + +// GenerateDoc will find if it exist the mermaid block in the markdown file with the right title +// and update it with the content describing this FSM. If it can not find the mermaid block, it will +// append it at the end of the file. The FSM package need to be compile with the tag `doc` for this +// to work. +func (f *FSM) GenerateDoc(_ string, _ string) error { + return errors.New("not compiled with doc tag") +}