Skip to content

Instantly share code, notes, and snippets.

@matthewp
Last active July 20, 2022 19:55
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save matthewp/ab4f314c992ad48667a38bff7fb6ff27 to your computer and use it in GitHub Desktop.
Save matthewp/ab4f314c992ad48667a38bff7fb6ff27 to your computer and use it in GitHub Desktop.
FSM DSL
export const HOLE = 'hole';
export const ASSIGN = 'assign';
<!doctype html>
<html lang="en">
<title>FSM DSL - Counter</title>
<main>
<p>Count: <span id="countValue"></span></p>
<button id="incrementButton" type="button">Increment</button>
<button id="decrementButton" type="button">Decrement</button>
</main>
<script type="module">
import { interpret } from '//unpkg.com/xstate/dist/xstate.web.js';
import { machine } from './dsl.js';
let counterMachine = machine`
context ${{ count: 0 }}
event INC
event DEC
action increment = assign count ${({ count }) => count + 1}
action decrement = assign count ${({ count }) => count - 1}
guard isNotMax = ${({ count }) => count < 10}
guard isNotMin = ${({ count }) => count > 0}
initial state active {
INC => isNotMax => increment
DEC => isNotMin => decrement
}
`;
const counterService = interpret(counterMachine)
.onTransition(state => {
setState(state);
})
.start();
function setState({ context, value }) {
countValue.textContent = context.count;
}
incrementButton.addEventListener('click', () => counterService.send('INC'));
decrementButton.addEventListener('click', () => counterService.send('DEC'));
</script>
import { parseTemplate } from './parse-template.js';
import { interpret, machine as xstateMachine } from './xstate.js';
function parse(strings, ...args) {
if(Array.isArray(strings)) {
return parseTemplate(strings, ...args);
} else {
throw new Error('Unexpected argument: ' + typeof strings);
}
}
function machine(strings, ...args) {
let ast = parse(strings, ...args);
return xstateMachine(ast);
}
export {
machine,
parse,
interpret
};
<!doctype html>
<html lang="en">
<title>FSM dsl</title>
<main>
<button id="myButton" type="button">Toggle</button>
<p>State: <strong id="stateValue"></strong></p>
<p>Times active: <span id="countValue"></span></p>
</main>
<script type="module">
import { interpret } from '//unpkg.com/xstate/dist/xstate.web.js';
import { machine } from './dsl.js';
let toggleMachine = machine`
event toggle
context ${{ count: 0 }}
guard isEnabled = ${state => state.enabled}
action increment = assign count ${state => state.count + 1}
initial state active {
toggle => inactive
entry increment
}
state inactive {
invoke ${() => new Promise(resolve => {setTimeout(resolve, 5000)})} {
done => active
error => state
}
}
state failure {
}
`;
const toggleService = interpret(toggleMachine)
.onTransition(state => {
setState(state);
})
.start();
function setState({ context, value }) {
stateValue.textContent = value;
countValue.textContent = context.count;
}
myButton.addEventListener('click', () => toggleService.send('toggle'));
</script>

FSM DSL

This repo creates a DSL for Finite State Machines. Only the absolute basics work but it's divided into:

  • A parser which produces an AST.
  • An export called machine which produces a FSM from the DSL with XState.

The idea is that because the parser produces an AST, this can be used to create state machines for any FSM library.

Example usage is in example.html, but the basics is:

let toggleMachine = machine`
 action toggle

 guard isEnabled = ${state => state.enabled}

 initial state active {
   toggle => inactive

   onEntry isEnabled
 }

 state inactive {
   toggle => active
 }
`;

const toggleService = interpret(toggleMachine)
  .onTransition(state => {
    console.log('New state:', state.value);
  })
  .start();
<!doctype html>
<html lang="en">
<title>FSM DSL - Login</title>
<style>
.error { color: tomato; }
</style>
<login-form></login-form>
<script type="module">
import { interpret } from '//unpkg.com/xstate/dist/xstate.web.js';
import { StacheDefineElement, type } from '//unpkg.com/can/everything.mjs';
import { machine } from './dsl.js';
function submissionError(state) {
let missing = [];
if(!state.login) missing.push('login');
if(!state.password) missing.push('password');
return `Cannot submit, missing [${missing.join(', ')}]`;
}
let loginMachine = machine`
context ${{ login: '', password: '' }}
action setLogin = assign login ${(_, ev) => ev.e.target.value}
action setPassword = assign password ${(_, ev) => ev.e.target.value}
action updateSubmissionError = assign submissionError ${submissionError}
action clearError = assign submissionError ${() => null}
guard canSubmit = ${({login, password}) => login && password}
guard hasError = ${({ submissionError }) => !!(submissionError)}
initial state form {
login => setLogin => input
password => setPassword => input
submit => validate
}
state input {
=> hasError => clearError => form
=> form
}
state validate {
=> canSubmit => complete
=> updateSubmissionError => form
}
state complete {
}
`;
const view = /* html */ `
<div class="login">
{{#if(this.isComplete)}}
<p>Thank you <strong>{{login}}</strong> for logging in!</p>
{{else}}
<label>
Login: <input type="text" name="login" on:input="this.sendEvent('login', scope.event)">
</label>
<label>
Password: <input type="password" name="password" on:input="this.sendEvent('password', scope.event)">
</label>
<p>
<label>Submit: <input type="submit" value="Submit" on:click="this.sendEvent('submit', scope.event)"></label>
</p>
{{#if(this.submissionError)}}
<p class="error">{{this.submissionError}}</p>
{{/if}}
{{#if(this.login)}}
<p>Your username: <strong>{{this.login}}</strong></p>
{{/if}}
{{/if}}
</div>
`;
class LoginForm extends StacheDefineElement {
static get view() { return view; }
static get define() {
return {
login: String,
password: String,
submissionError: type.maybe(String),
isComplete: {
get() {
return this.current && this.current.matches('complete');
}
}
};
}
sendEvent(type, event) {
this.send({ type, e: event })
}
connected() {
this.machine = loginMachine.withContext({
...loginMachine.context
});
this.service = interpret(this.machine).onTransition(state => {
if(state.changed) {
this.current = state;
Object.assign(this, state.context);
}
});
this.current = this.service.initialState;
this.send = this.service.send;
this.service.start();
return () => this.service.stop();
}
}
customElements.define('login-form', LoginForm);
</script>
<!doctype html>
<html lang="en">
<title>FSM DSL - Login</title>
<login-form></login-form>
<script type="module">
import { interpret } from '//unpkg.com/xstate/dist/xstate.web.js';
import { html, component, useState, useMemo, useEffect } from '//unpkg.com/haunted/haunted.js';
import { machine } from './dsl.js';
function useMachine(existingMachine) {
let machine = useMemo(() => {
return existingMachine.withContext({
...existingMachine.context
});
}, []);
let service = useMemo(() => {
return interpret(machine).onTransition(state => {
if(state.changed) {
setCurrent(state);
}
});
}, []);
let [current, setCurrent] = useState(service.initialState);
useEffect(() => {
service.start();
return () => service.stop();
}, []);
return [current, service.send, service];
}
function submissionError(state) {
let missing = [];
if(!state.login) missing.push('login');
if(!state.password) missing.push('password');
return `Cannot submit, missing [${missing.join(', ')}]`;
}
let loginMachine = machine`
context ${{ login: '', password: '' }}
action setLogin = assign login ${(_, ev) => ev.e.target.value}
action setPassword = assign password ${(_, ev) => ev.e.target.value}
action updateSubmissionError = assign submissionError ${submissionError}
action clearError = assign submissionError ${() => null}
guard canSubmit = ${({login, password}) => login && password}
guard hasError = ${({ submissionError }) => !!(submissionError)}
initial state form {
login => setLogin => input
password => setPassword => input
submit => validate
}
state input {
=> hasError => clearError => form
=> form
}
state validate {
=> canSubmit => complete
=> updateSubmissionError => form
}
state complete {
}
`;
function LoginForm() {
let [current, send] = useMachine(loginMachine);
let { login, password, submissionError } = current.context;
return html`
<div class="login">
<style>
.error { color: tomato; }
</style>
${current.matches('complete') ? html`
<p>Thank you <strong>${login}</strong> for logging in!<p>
` : html`
<label>
Login: <input type="text" name="login" @input=${e => send({ type: 'login', e })}>
</label>
<label>
Password: <input type="password" name="password" @input=${e => send({ type: 'password', e })}>
</label>
<p>
<label>Submit: <input type="submit" value="Submit" @click=${e => send('submit')}></label>
</p>
${submissionError ? html`
<p class="error">${submissionError}</p>
`: ''}
${(login || password) ? html`
<p>Your username: <strong>${login}</strong></p>
` : ''}
`}
</div>
`;
}
customElements.define('login-form', component(LoginForm));
</script>
import { HOLE } from './constants.js';
import { parse } from './parser.js';
function createGuardCallback(fn) {
return fn;
}
function createAssignCallback(fn) {
return fn;
}
function parseTemplate(strings, ...args) {
let string = strings.join(HOLE);
let holeIndex = 0;
let ast = parse(string, node => {
let index = holeIndex;
holeIndex++;
return args[index];
});
return ast;
}
export {
parseTemplate
};
import {
createEvent,
createAction,
createState,
createGuard,
createTransition,
createTransitionAction,
createContext,
createInvoke
} from './types.js';
import { HOLE } from './constants.js';
const WHITESPACE = /\s/;
const LETTERS = /[a-z]/i;
function parse(input, onHole) {
let current = 0;
let nodes = [];
let body = nodes;
let length = input.length;
let char, word;
function peek() {
return input[current + 1];
}
function isNewLine() {
return char === '\n';
}
function isHole() {
return word === HOLE;
}
let state = {
scopes: [],
currentScope: new Map(),
inWord: false,
inEvent: false,
inAssignment: false,
inGuard: false,
// Actions
inAction: false,
actionName: null,
inAssign: false,
assignName: null,
// Guard
guardName: null,
// State
inInitial: false,
inState: false,
inStateBody: false,
inTransition: false,
transitionQueue: null,
transitionEvent: null,
stateParent: null,
// Invoke
inInvoke: false,
inInvokeBody: false,
invokeParent: null,
// Action types
inTransitionAction: false,
actionType: null,
// Context
inContext: false
};
while(current < length) {
char = input[current];
// Whitespace
if(WHITESPACE.test(char)) {
if(state.inWord) {
switch(word) {
case 'event': {
state.inEvent = true;
break;
}
case 'action': {
state.inAction = true;
break;
}
case 'guard': {
state.inGuard = true;
break;
}
case 'initial': {
state.inInitial = true;
}
case 'state': {
state.inState = true;
break;
}
case 'entry':
case 'exit': {
state.inTransitionAction = true;
state.actionType = word;
break;
}
case 'assign': {
if(!state.inAction) {
throw new Error('Expect keyword assign in an action');
}
state.inAssign = true;
break;
}
case 'context': {
state.inContext = true;
break;
}
case 'invoke': {
state.inInvoke = true;
break;
}
default: {
if(state.inEvent) {
let eventNode = createEvent(word);
nodes.push(eventNode);
state.currentScope.set(word, eventNode);
state.inEvent = false;
} else if(state.inInvoke && !state.inInvokeBody) {
let fn;
if(isHole()) {
fn = onHole();
} else {
throw new Error('Expected a function declaration');
}
let invokeNode = createInvoke(fn);
state.invokeParent = nodes;
nodes.push(invokeNode);
nodes = invokeNode.children;
} else if(state.inState && !state.inStateBody) {
let stateNode = createState(word, state.inInitial);
state.stateNode = stateNode;
state.stateParent = nodes;
nodes.push(stateNode);
nodes = stateNode.children;
} else if(state.inGuard) {
if(state.inAssignment) {
let guardNode = createGuard(state.guardName);
if(isHole()) {
guardNode.callback = onHole(guardNode);
}
nodes.push(guardNode);
state.currentScope.set(state.guardName, guardNode);
state.inGuard = false;
state.inAssignment = false;
state.guardName = null;
} else {
state.guardName = word;
}
} else if(state.inAction) {
if(state.inAssign) {
if(isHole()) {
let actionNode = createAction(state.actionName, state.assignName);
actionNode.assignCallback = onHole(actionNode);
nodes.push(actionNode);
state.currentScope.set(state.actionName, actionNode);
state.inAssign = false;
state.inAssignment = false;
state.inAction = false;
state.actionName = null;
state.assignName = null;
} else {
state.assignName = word;
}
} else {
state.actionName = word;
}
} else if(state.inAssignment) {
throw new Error('Unknown assignment ' + word);
} else if(state.inTransition) {
state.transitionQueue.push(word);
if(isNewLine()) {
let transitionTo;
let actions;
let cond;
for(let identifier of state.transitionQueue) {
if(state.currentScope.has(identifier)) {
let identifierNode = state.currentScope.get(identifier);
if(identifierNode.isAction()) {
transitionTo = null;
if(!actions) actions = [];
actions.push(identifier);
} else if(identifierNode.isGuard()) {
cond = identifier;
}
} else {
transitionTo = identifier;
}
}
nodes.push(createTransition(state.transitionEvent, transitionTo, actions, cond));
state.inTransition = false;
state.transitionEvent = null;
state.transitionQueue = null;
}
} else if(state.inTransitionAction) {
nodes.push(createTransitionAction(word, state.actionType));
// TODO allow multiple
state.inTransitionAction = false;
state.actionType = null;
} else if(state.inContext) {
if(word === HOLE) {
let contextNode = createContext();
contextNode.value = onHole(contextNode);
nodes.push(contextNode);
state.onContext = false;
}
}
}
}
}
state.inWord = false;
if(isNewLine()) {
word = null;
}
current++;
continue;
}
// Comments
if(char === '/' && peek() === '/') {
current++;
while(!isNewLine()) {
char = input[++current];
}
continue;
}
// Letters
if(LETTERS.test(char)) {
let value = '';
while(LETTERS.test(char)) {
value += char;
char = input[++current];
}
word = value;
state.inWord = true;
continue;
}
// Transitions
if(char === '=') {
if(peek() === '>') {
if(state.inInvoke || state.inState) {
if(!state.inTransition) {
state.transitionEvent = word || '';
state.inTransition = true;
state.transitionQueue = [];
}
} else {
throw new Error('Cannot define transitions outside of state');
}
current++;
} else {
state.inAssignment = true;
}
current++;
continue;
}
if(char === '{') {
if(state.inInvoke) {
state.inInvokeBody = true;
} else if(state.inState) {
state.inStateBody = true;
} else {
throw new Error('Unexpected token {');
}
current++;
continue;
}
if(char === '}') {
if(state.inInvoke) {
nodes = state.invokeParent;
state.inInvoke = false;
state.inInvokeBody = false;
state.invokeParent = null;
} else if(state.inState) {
// Reset the current node
nodes = state.stateParent;
state.stateParent = null;
state.stateNode = null;
state.inState = false;
state.inStateBody = false;
state.inInitial = false;
} else {
throw new Error('Unexpected token }');
}
current++;
continue;
}
current++;
}
return {
body
};
}
export {
parse
};
const type = {
isEvent() {
return this.type === 'event';
},
isAction() {
return this.type === 'action';
},
isAssignAction() {
return this.isAction() && !!this.assign;
},
isGuard() {
return this.type === 'guard';
},
isState() {
return this.type === 'state';
},
isTransition() {
return this.type === 'transition';
},
isTransitionAction() {
return this.type === 'transition-action';
},
isContext() {
return this.type === 'context';
},
isInvoke() {
return this.type === 'invoke';
}
};
function valueEnumerable(value) {
return {
enumerable: true,
value
};
}
function extendType(typeName, desc) {
desc.type = valueEnumerable(typeName);
return Object.create(type, desc);
}
const eventType = extendType('event', {});
const actionType = extendType('action', {});
const stateType = extendType('state', {});
const guardType = extendType('guard', {});
const transitionType = extendType('transition', {});
const transitionActionType = extendType('transition-action', {});
const contextType = extendType('context', {});
const invokeType = extendType('invoke', {});
export function createEvent(name) {
return Object.create(eventType, {
name: valueEnumerable(name)
});
}
export function createAction(name, assign) {
return Object.create(actionType, {
name: valueEnumerable(name),
assign: valueEnumerable(assign)
});
}
export function createState(name, initial = false) {
return Object.create(stateType, {
name: valueEnumerable(name),
initial: valueEnumerable(initial),
children: valueEnumerable([])
});
}
export function createGuard(name) {
return Object.create(guardType, {
name: valueEnumerable(name)
});
}
export function createTransition(event, target, actions, cond) {
return Object.create(transitionType, {
event: valueEnumerable(event),
target: valueEnumerable(target),
actions: valueEnumerable(actions),
cond: valueEnumerable(cond)
});
}
export function createTransitionAction(name, type) {
return Object.create(transitionActionType, {
name: valueEnumerable(name),
transitionType: valueEnumerable(type)
});
}
export function createContext() {
return Object.create(contextType);
}
export function createInvoke(fn) {
return Object.create(invokeType, {
callback: valueEnumerable(fn),
children: valueEnumerable([])
});
}
import { Machine, assign, interpret } from 'https://unpkg.com/xstate/dist/xstate.web.js';
const invokeEventMap = new Map([
['done', 'onDone'],
['error', 'onError']
]);
function createConfig(ast) {
let config = {
id: 'unknown',
context: {},
states: {}
};
let options = {
actions: {},
guards: {}
};
for(let node of ast.body) {
if(node.isState()) {
let state = {
on: {},
entry: []
};
for(let child of node.children) {
if(child.isTransition()) {
let transition;
if(child.actions) {
transition = {
actions: Array.from(child.actions),
cond: child.cond
};
if(child.target) {
transition.target = child.target;
}
} else {
transition = {
target: child.target,
cond: child.cond
}
}
if(state.on[child.event]) {
if(Array.isArray(state.on[child.event])) {
state.on[child.event].push(transition);
} else {
let e = state.on[child.event];
state.on[child.event] = [e, transition];
}
} else {
state.on[child.event] = transition;
}
} else if(child.isTransitionAction()) {
state[child.transitionType].push(child.name);
} else if(child.isInvoke()) {
let invoke = {
src: child.callback
};
for(let { event, target } of child.children) {
invoke[invokeEventMap.get(event)] = { target };
}
state.invoke = invoke;
} else {
throw new Error('Unknown child of state: ' + child.type);
}
}
config.states[node.name] = state;
if(node.initial) {
config.initial = node.name;
}
} else if(node.isAction()) {
let actionValue = node.isAssignAction() ? assign({
[node.assign]: node.assignCallback
}) : node.value; // TODO we don't support non-assigns yet.
options.actions[node.name] = actionValue;
} else if(node.isContext()) {
config.context = node.value;
} else if(node.isGuard()) {
options.guards[node.name] = node.callback;
}
}
return [config, options];
}
function machine(ast) {
let [config, options] = createConfig(ast);
return Machine(config, options);
}
export {
createConfig,
machine,
interpret
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment