@fabiospampinato/fsm
Version:
Finite State Machine implementation, with support for guards and enter/exit events.
249 lines (135 loc) • 5.55 kB
text/typescript
/* IMPORT */
import compact = require ( 'lodash/compact' );
import get = require ( 'lodash/get' );
import includes = require ( 'lodash/includes' );
import isFunction = require ( 'lodash/isFunction' );
import isString = require ( 'lodash/isString' );
import isUndefined = require ( 'lodash/isUndefined' );
import FIFO, { type as FIFOType } from '@fabiospampinato/fifo';
import Lockable, { type as LockableType } from '@fabiospampinato/lockable';
import {guard, model, state, states, transition, transitionObj, statesObj} from './types';
/* FSM */
//TODO: Add support for actions on submodules, like `history.start`: 'end'
//TODO: Add support for an `action` key on the transition object, that would be the executed action
class FSM {
/* PROPERTIES */
model: model;
states: statesObj;
queue: FIFOType;
initial: state;
processing: LockableType;
state: state;
/* CONSTRUCTOR */
constructor ( model: model, states: statesObj, initial: state ) {
this.model = model;
this.states = states;
this.queue = new FIFO ();
this.initial = initial;
this.processing = new Lockable ();
this.set ( this.initial );
}
/* UTILITIES */
_isValidState ( state: state ): boolean {
return this.states.hasOwnProperty ( state );
}
_isValidTransition ( state: state, transition: transition ): boolean {
return this._isValidState ( state ) && !!this._getTransitionState ( state, transition ) && this._isValidTransitionGuard ( state, transition );
}
_isValidTransitionGuard ( state: state, transition: transition ): boolean {
const guards = this._getTransitionGuard ( state, transition );
if ( !guards ) return true;
for ( let guard of guards.split ( '|' ) ) {
const parts = guard.match ( /^(!?)(\w+)(?:\.(\w+))?$/ );
if ( !parts ) throw new Error ( '[fsm] Invalid guard' );
const affirmative = ( parts[1] !== '!' ),
method = compact ( parts.slice ( 2 ) ).join ( '.' );
if ( !!this._callModel ( method ) !== affirmative ) return false;
}
return true;
}
_getTransition ( state: state, transition: transition ): transitionObj | undefined {
const stateObj = this.states[state];
if ( !stateObj.hasOwnProperty ( 'transitions' ) ) return;
return stateObj.transitions[transition];
}
_getTransitionState ( state: state, transition: transition ): state | undefined {
const transitionObj = this._getTransition ( state, transition );
if ( isUndefined ( transitionObj ) || isString ( transitionObj ) ) return transitionObj;
return transitionObj.state;
}
_getTransitionGuard ( state: state, transition: transition ): guard | undefined {
const transitionObj = this._getTransition ( state, transition );
if ( isUndefined ( transitionObj ) || isString ( transitionObj ) ) return;
return transitionObj.guard;
}
_getExistsEnters ( prevState: state, nextState: state ): [states, states] { // Exists just to provide a DRYer implementation of HSM
if ( prevState === nextState ) return [[], []];
return [[prevState], [nextState]];
}
_callModel ( path: string, args: any[] = [] ) {
const method = get ( this.model, path );
if ( !isFunction ( method ) ) return;
const context = includes ( path, '.' ) ? get ( this.model, path.split ( '.' ).slice ( 0, -1 ).join ( '.' ) ) : this.model;
return method.apply ( context, args );
}
/* GET */
get (): state {
return this.state;
}
/* SET */
set ( state: state ): this {
if ( !this._isValidState ( state ) ) throw new Error ( `[fsm] Invalid state "${state}"` );
this.state = state;
return this;
}
/* RESET */
reset (): this {
return this.set ( this.initial );
}
/* IS */
is ( state: state ): boolean {
return this.state === state;
}
isDoable ( transition: transition ): boolean {
return this._isValidTransition ( this.state, transition );
}
/* TRANSITION */
do ( ...args ): this { // Just an alias of `transition`
return this.transition.call ( this, ...args );
}
transition ( transition: transition ): this;
transition ( transition: transition, ...args ): this;
transition ( transition: transition, ...args ): this {
this.queue.add ( [transition, ...args] );
if ( this.processing.isLocked () ) return this;
this.processing.lock ();
while ( true ) {
const next = this.queue.next ();
if ( !next ) break;
this._transition.apply ( this, next );
}
this.processing.unlock ();
return this;
}
_transition ( transition: string, ...args ) {
if ( !this.isDoable ( transition ) ) throw new Error ( `[fsm] Invalid transition "${transition}" from state "${this.state}"` );
let nextState = this._getTransitionState ( this.state, transition );
if ( !nextState ) throw new Error ( `[fsm] Invalid transition "${transition}" from state "${this.state}"` );
if ( nextState === '*' ) nextState = this.state; // `*` states always point to the current state
const [exits, enters] = this._getExistsEnters ( this.state, nextState );
exits.forEach ( this._exit.bind ( this ) );
this._callModel ( transition, args );
enters.forEach ( this._enter.bind ( this ) );
this.set ( nextState );
}
/* EVENTS */
_exit ( state: state ): void {
this._callModel ( `${state}Exit` );
}
_enter ( state: state ): void {
this.set ( state );
this._callModel ( `${state}Enter` );
}
}
/* EXPORT */
export default FSM;