/** * Copyright (C) 2019, Dmitriy Pleshevskiy * * it-fsm is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * it-fsm is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with it-fsm. If not, see . */ export type StateTransitions = WeakMap< State, WeakSet> >; export type StateOrName = | State | SN; export type SourceTransitions = Array<[SN, Array]>; export type SourceNamedTransitions = Array< [SN, Record] >; export type SourceActions = Record>; export const _states = Symbol("states"); export const _transitions = Symbol("transitions"); export const _actions = Symbol("actions"); export const _prevState = Symbol("previous state"); export const _currState = Symbol("current state"); export class StateMachineBuilder { [_states]: Map>; [_transitions]: SourceTransitions | undefined; [_actions]: SourceActions | undefined; constructor() { this[_states] = new Map(); } withTransitions( sourceTransitions: SourceTransitions | SourceNamedTransitions, ) { const [t, a] = (sourceTransitions as Array<[SN, Array | Record]>) .reduce( ([transitions, actions], [fromState, sources]) => { const toStates = Array.isArray(sources) ? sources : Object.values(sources); transitions.push([fromState, toStates]); if (!Array.isArray(sources)) { Object.entries(sources).forEach(([actionName, toState]) => { const actionTransitions = actions[actionName] || []; actions[actionName] = [ ...actionTransitions, [fromState, toState], ]; }); } return [transitions, actions]; }, [[], {}] as [SourceTransitions, SourceActions], ); this[_transitions] = t; this[_actions] = a; return this; } withStates(names: SN[], actions?: Events) { names.forEach((name) => this.addStateUnchecked(name, actions)); return this; } withState(name: SN, actions?: Events) { this.addStateUnchecked(name, actions); return this; } private addStateUnchecked( name: SN, actions?: Events, ) { const oldActions = this[_states].get(name); return this[_states].set(name, { ...oldActions, ...actions }); } build(currentStateName: SN) { const states = this.buildStates(); const transitions = this.buildTransitions(states); const actions = this.buildActions(states); const currState = validStateFromName(states, currentStateName); return new StateMachine(currState, states, { transitions, actions, }); } private buildStates() { return Array.from(this[_states].entries()) .map((params) => new State(...params)); } private buildTransitions(states: State[]) { const sourceTransitions = this[_transitions]; if (!sourceTransitions) return undefined; return new WeakMap( sourceTransitions.map(([from, toStates]) => [ validStateFromName(states, from), new WeakSet(toStates.map(validStateFromName.bind(null, states))), ]), ); } private buildActions(states: State[]): Actions | undefined { const actions = this[_actions]; if (!actions) return undefined; return new Map( Object.entries(actions).map(([actionName, variants]) => [ actionName, variants.map(([fromState, toState]) => [ validStateFromName(states, fromState), validStateFromName(states, toState), ]), ]), ); } } export interface StateMachineOpts { transitions?: StateTransitions; actions?: Actions; } export type Actions = Map< string, Array<[State, State]> >; export class StateMachine { [_states]: State[]; [_transitions]: StateTransitions; [_actions]: Actions; [_prevState]: State | undefined; [_currState]: State; get currentState() { return this[_currState]; } constructor( currentState: State, states: State[], { transitions, actions }: StateMachineOpts, ) { this[_currState] = currentState; this[_states] = states; this[_transitions] = transitions || new Map(); this[_actions] = actions || new Map(); } async tryChangeState( state: StateOrName, context: Ctx, ) { const fromState = validState(this.currentState); const toState = validNormalizedState(this[_states], state); if ( !this.hasTransition(toState) || !fromState.exit(fromState, toState, context) ) { throw new FsmError( `cannot change state from "${fromState.name}" to "${toState.name}"`, ); } await toState.entry(fromState, toState, context); this[_prevState] = fromState; this[_currState] = toState; return this[_currState]; } maybeChangeState(state: StateOrName, context: Ctx) { return this.tryChangeState(state, context).catch(() => null); } hasTransition(to: StateOrName) { return hasTransition( this[_transitions], this[_currState], validNormalizedState(this[_states], to), ); } allowedTransitionStates() { return this[_states].filter( hasTransition.bind(null, this[_transitions], this[_currState]), ); } allowedTransitionStateNames() { return this.allowedTransitionStates().map(String); } trigger(actionName: string, context: Ctx) { const currState = this[_currState]; const variants = this[_actions]?.get(actionName); if (!variants) return currState; const [, toState] = variants.find(([fromState]) => fromState === currState) || []; if (!toState) return currState; return this.tryChangeState(toState, context); } } export const _stateName = Symbol("state name"); export const _stateEvents = Symbol("state events"); export interface Events { beforeExit?( fromState: State, toState: State, context: Ctx, ): boolean; onEntry?( fromState: State, toState: State, context: Ctx, ): Promise | void; } export class State { [_stateEvents]: Events; [_stateName]: SN; get name(): SN { return this[_stateName]; } constructor(name: SN, events: Events = {}) { this[_stateName] = name; this[_stateEvents] = events; } async entry( fromState: State, toState: State, context: Ctx, ) { const event = this[_stateEvents].onEntry; if (isFn(event)) { await event(fromState, toState, context); } } exit(fromState: State, toState: State, context: Ctx) { const event = this[_stateEvents].beforeExit; return isFn(event) ? event(fromState, toState, context) : true; } toString() { return this.name; } toJSON() { return this.toString(); } } export function validNormalizedState( states: State[], state: StateOrName, ) { return validState(normalizeState(states, state)); } export function normalizeState( states: State[], state: StateOrName, ): State | undefined { return isStr(state) ? stateFromName(states, state) : state; } export function validStateFromName( states: State[], name: SN, ) { return validState(stateFromName(states, name)); } export function stateFromName( states: State[], name: SN, ) { return states.find((state) => state.name === name); } export function validState( val: unknown, ): State { if (!isState(val)) { throw new TypeError("an instance of State class is expected"); } return val; } export function isState( val: unknown, ): val is State { return val instanceof State; } function hasTransition( transitions: StateTransitions, from: State, to: State, ) { return transitions.get(from)?.has(to) || false; } function isStr(val: unknown): val is string { return typeof val === "string"; } // deno-lint-ignore ban-types function isFn(val: unknown): val is Function { return typeof val === "function"; } export class FsmError extends Error {}