Frédéric G. MARAND 1 year ago
parent
commit
950b4ce20f
5 changed files with 400 additions and 0 deletions
  1. 3 0
      .gitignore
  2. 5 0
      go.mod
  3. 2 0
      go.sum
  4. 357 0
      machine.go
  5. 33 0
      result.go

+ 3 - 0
.gitignore

@@ -24,3 +24,6 @@ _testmain.go
 *.test
 *.prof
 
+# Project-specific exclusions
+php__phplib
+.idea

+ 5 - 0
go.mod

@@ -0,0 +1,5 @@
+module code.osinet.fr/fgm/fsm
+
+go 1.20
+
+require golang.org/x/exp v0.0.0-20230321023759-10a507213a29

+ 2 - 0
go.sum

@@ -0,0 +1,2 @@
+golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug=
+golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=

+ 357 - 0
machine.go

@@ -0,0 +1,357 @@
+/*
+Package fsm defines an embeddable finite state machine.
+
+In its initial version, it is a straight port of the 2012 PHP
+OSInet\Finite_State_Machine\Machine available in https://code.osinet.fr/fgm/php__phplib.git
+
+See http://wiki.audean.com/fsm/fsm for details.
+
+Copyright  (c) 2023 Ouest Systèmes Informatiques
+
+License    GPL-3.0
+*/
+package fsm
+
+import (
+	"errors"
+	"fmt"
+	"log"
+
+	"golang.org/x/exp/maps"
+	"golang.org/x/exp/slices"
+)
+
+type (
+	Event      any
+	EventMode  int
+	State      string
+	Transition map[Event][2]Result
+)
+
+const (
+	Version   string = "12D22"
+	IdleEvent        = "idle" // must not change name: method fIdle depends on it
+
+	InitState  State = "init"
+	FinalState State = "final"
+)
+
+const (
+	EventInvalid EventMode = iota // Should not be seen
+	EventNormal                   // Processes events
+	EventQueue                    // Queue events for later use
+	EventSink                     // Throw away events
+)
+
+// Machine is a composable state machine.
+//
+// Applications must create an actual FSM composing such a Machine, in which they will:
+//  - define the fTransitions table, usually in their constructor
+//  - invoke the parent constructor to set up the FSM from the transitions table
+//  - create "f_foo" functions for each "foo" event, except "idle", which is builtin.
+//
+// Applications may:
+//  - disable event processing by setting $this->event to one of the fsm::EVENT_* constants
+//  - disable post-event actions by setting $this->allowsActions to false
+//  - disable the builtin idle event by setting $this->idleWork to false
+//  - query the current state by using $this->getState()
+//  - send an idle event by using $this->idle()
+//  - submit any event (including idle) by using $this->ApplyEvent($eventName)
+type Machine struct {
+	AllowActions bool
+	EventMode    EventMode
+	IdleWork     bool
+	Queue        []any
+	State        State
+	Transitions  map[State]Transition
+}
+
+// checkTransitions verifies than a transitions graph has been defined.
+//
+// Since applications are not expected to modify the machine once initialized,
+// it is not meant to be used beyond New.
+func (m Machine) checkTransitions() error {
+	if len(m.Transitions) == 0 {
+		return errors.New("no FSM processing is allowed without a transitions table")
+	}
+	if _, ok := m.Transitions[InitState]; ok {
+		return errors.New("transitions map does not have any initial state")
+	}
+	return nil
+}
+
+// New initializes the FSM to the first state in the transitions table
+func New(transitions map[State]Transition) (*Machine, error) {
+	m := Machine{
+		AllowActions: true,
+		IdleWork:     true,
+		EventMode:    EventNormal,
+		Queue:        []any{},     // Queue is the event queue for EVENT_QUEUE mode
+		State:        InitState,   // State holds the current state of the machine
+		Transitions:  transitions, // Transitions holds the transitions table
+	}
+	if err := m.checkTransitions(); err != nil {
+		return nil, err
+	}
+	return &m, nil
+}
+
+// AcceptedEvents returns the list of events accepted in the current state.
+func (m Machine) AcceptedEvents() []Event {
+	t, ok := m.Transitions[m.State]
+	if !ok {
+		return nil
+	}
+	events := maps.Keys(t)
+	return events
+}
+
+// AcceptedOutcomes returns the list of outcomes accepted in the current state for the given event
+func (m Machine) AcceptedOutcomes(e Event) ([2]Result, error) {
+	res, ok := m.Transitions[m.State][e]
+	if !ok {
+		return [2]Result{}, fmt.Errorf("event %q not accepted in state %q", e, m.State)
+	}
+	return res, nil
+}
+
+// IsEventAllowed verifies whether the given event is accepted in the current state
+func (m Machine) IsEventAllowed(e Event) bool {
+	t := m.Transitions[m.State]
+	isIt := slices.Contains(maps.Keys(t), e)
+	return isIt
+}
+
+// IsOutcomeAllowed checks whether a given outcome available for a given event, considering the current state.
+func (m Machine) IsOutcomeAllowed(e Event, outcome Result) bool {
+	if !m.IsEventAllowed(e) {
+		return false
+	}
+	res, err := m.AcceptedOutcomes(e)
+	if err != nil {
+		return false
+	}
+	isIt := outcome == res[0] || outcome == res[1]
+	return isIt
+}
+
+// ApplyEvent applies both the given event and the resulting event chain if applicable.
+func (m *Machine) ApplyEvent(e Event) Result {
+	var res Result
+	for {
+		var next Event
+		res = m.ApplySimpleEvent(e)
+		if m.AllowActions {
+			next = res.Action
+		} else {
+			next = nil
+		}
+		if next != nil {
+			break
+		}
+	}
+
+	return res
+}
+
+// ApplySimpleEvent is a helper for ApplyEvent that does not implement the post-transition action
+//
+// See ApplyEvent
+func (m *Machine) ApplySimpleEvent(e Event) Result {
+	currentState := m.State
+
+	if e == IdleEvent && !m.IdleWork {
+		return Result{}
+	}
+
+	if !m.IsEventAllowed(e) {
+	   log.Fatalf("Event %q not allowed in current state %q.\n", e, currentState)
+	}
+return Result{}
+}
+
+	$outcomes = $this- > getAcceptedOutcomes($eventName)
+	$methodName = "event$eventName"
+	if !method_exists($this, $methodName)) {
+	die (func_name().": missing method "
+	.get_class($this)."::$methodName. Aborting.\n")
+	}
+	$outcome = $this- >$methodName()
+	if !in_array($outcome, $outcomes)) {
+	throw new \Exception(func_name()
+	.": event guard. Transition on \"$eventName\" return invalid outcome: "
+	.print_r($outcome, true)
+	." for state $this->fState\n")
+	}
+
+	$transition = &$this- > fTransitions[$currentState][$eventName][$outcome]
+$result = new Result (
+$outcome,
+$transition[0],
+$transition[1]
+)
+if (isset($result->fsmState)) {
+$this->fState = $result->fsmState
+}
+/* print_r($this->fTransitions[$currentState][$eventName]);
+   var_dump($result); */
+if (!isset($outcome)) {
+$outcome = 'NULL'
+}
+
+/*
+    echo func_name()
+   . ": $currentState: " . $eventName . '[' . $outcome . ']'
+   . " -> $this->fState / " . $result->fsmAction . PHP_EOL;
+*/
+return result
+}
+
+/**
+ * Default event.
+ *
+ * @return boolean
+ */
+public function fIdle() {
+return TRUE
+}
+
+/**
+ * Apply an fsm::IDLE_EVENT event. Do not confuse with fIdle !
+ *
+ * @return Result
+ */
+public function idle() {
+return $this->applyEvent(self::IDLE_EVENT)
+}
+
+/**
+ * return the current operating mode of the FSM
+ * @return int
+ */
+public function getEventMode() {
+return $this->fEventMode
+}
+
+/**
+ * set the current operating mode for the FSM
+ *
+ * @param int $mode fsm::EVENT_* constants
+ * @return int $mode fsm::EVENT_* constants
+ */
+public function setEventMode($mode) {
+switch ($mode) {
+case self::EVENT_NORMAL:
+if (count($this->fQueue) > 0) {
+while (($event = array_shift($this->fQueue)) != = NULL) {
+$this->applyEvent($event)
+}
+}
+break
+
+// Nothing special to do
+case self::EVENT_QUEUE:
+break
+
+// Empty queue if needed
+case self::EVENT_SINK:
+if (count($this->fQueue) > 0) {
+$this->fQueue = array()
+}
+break
+default:
+throw new Exception("Trying to set unknown FSM mode $mode")
+}
+
+$this->fEventMode = $mode
+return $this->fEventMode
+}
+
+/**
+ * Load the fTransitions table from an external resource.
+ *
+ * @param string $url
+ *   Optional: defaults to the name of the class, . ".xml"
+ */
+public function loadFsm($url = NULL) {
+$osd = FALSE // on screen display (debug)
+if ($osd) {
+echo "Loading FSM from $url\n"
+}
+
+if (!isset($url)) {
+$url = get_class($this).".xml"
+}
+
+$fsm = simplexml_load_file($url)
+$fsmVersion = (string) $fsm['fsm_version']
+if ($fsmVersion != = '1.3') {
+die("Revision $fsmVersion of schema is not supported.\n")
+}
+
+$this->idleWork = ($fsm['idle_work']     == 1)
+$this->allowActions = ($fsm['allow_actions'] == 1)
+$this->fTransitions = array()
+$t = &$this->fTransitions
+
+// (string) casts in this loop are required: RHS is a SimpleXMLElement
+foreach ($fsm->state as $state) {
+$id = (string) $state['id']
+if ($osd) {
+echo "State $id :\n"
+}
+$t[$id] = array()
+switch ($id) {
+case self::INIT_STATE:
+if ($osd) {
+echo "  Initial state\n"
+}
+break
+case self::FINAL_STATE:
+if ($osd) {
+echo "  Final state\n"
+}
+break
+}
+
+foreach ($state->event as $event) {
+$name = (string) $event['name']
+if ($osd) {
+echo "  Event $name"
+}
+
+if (!isset($event['type'])) {
+$event['type'] = 'void'
+}
+
+$eventType = (string) $event['type']
+if ($osd) {
+echo ", type $eventType\n"
+}
+
+foreach ($event as $next) {
+if ($event['type'] == 'void') {
+$next['result'] = 'always'
+$result = null
+} else {
+$result = (string) $next['result']
+}
+
+if (!isset($next['state'])) {
+$next['state'] = (string) $state['id']
+}
+if ($osd) {
+echo "    Next(".$next['result'].') = '.$next['state']
+if (isset($next['action'])) {
+echo " + event ".$next['action']
+}
+echo PHP_EOL
+}
+$t[$id][$name][$result] = array(
+(string) $next['state'],
+(string) $next['action'])
+}
+}
+}
+}
+}

+ 33 - 0
result.go

@@ -0,0 +1,33 @@
+package fsm
+
+// Result defines a possible outcome for a given FSM Transition.
+type Result struct {
+	/**
+	 * The return value of the event handler.
+	 *
+	 * @var mixed
+	 */
+	Return any
+
+	/**
+	 * State is the state to which the FSM must change when handling this Result.
+	 *
+	 * If empty, do not change the current state.
+	 */
+	State
+
+	/**
+	 * Action is the name of an event to be fired after the state change has been applied.
+	 *
+	 * @var string
+	 */
+	Action Event
+}
+
+func NewResult(ret any, state State, action Event) *Result {
+	return &Result{
+		Action: action,
+		Return: ret,
+		State:  state,
+	}
+}