slim
Version:
Compile a Kingly state machine drawn in the yed professional graph editor down to JavaScript
396 lines (359 loc) • 17.8 kB
JavaScript
const {INIT_STATE, INIT_EVENT} = require('kingly');
const {mapOverTree} = require('fp-rosetree');
const {lensPath, view, mergeAll, concat, forEachObjIndexed, find, difference} = require('ramda');
const nearley = require("nearley");
const yedEdgeLabelGrammar = require("./yedEdgeLabelGrammar.js");
const {
handleAggregateEdgesPerFromEventKeyErrors,
T,
tryCatchFactory,
Yed2KinglyConversionError,
handleParseGraphMlStringErrors,
isCompoundState,
isInitialTransition,
isSimplifiableSyntax,
isTopLevelInitTransition,
getYedParentNode,
computeKinglyDestinationState,
mapActionFactoryStrToActionFactoryFn: defaultMapActionFactoryStrToActionFactoryFn,
mapGuardStrToGuardFn: defaultMapGuardStrToGuardFn,
yedState2KinglyState,
parseGraphMlString,
markFunctionStr,
markFunctionNoop,
markGuardNoop,
trimInside,
} = require('./helpers');
const {SEP, YED_ENTRY_STATE} = require('./properties');
// Lenses for traversing the syntax tree
// Note: that could be easier with a xpath query maybe?
const getYedEdgeLabel = edgeML => {
const data = Array.isArray(edgeML.data) ? edgeML.data : [edgeML.data];
const d10Record = data.find(d => d['@_key'] === 'd10');
return view(lensPath(['y:PolyLineEdge', 'y:EdgeLabel', '#text']), d10Record);
};
// DOC: we remove \n and \r and extra spaces from the labels
const getLabel = (graphObj) => {
const graphData = graphObj.data;
const yedNodeId = graphObj['@_id'];
const d6Key = Array.isArray(graphData)
? find(keyRow => keyRow ['@_key'] === 'd6', graphData)
: graphData['@_key'] === 'd6' ? graphData : null
// if (typeof d6Key === 'undefined') console.warn(`getLabel > [yedNodeId, ""]`, [yedNodeId, ""])
if (typeof d6Key === 'undefined') return [yedNodeId, ""]
if (isCompoundState(graphObj)) {
const _groupNodes = view(lensPath(['y:ProxyAutoBoundsNode', 'y:Realizers', 'y:GroupNode']), d6Key);
const groupNodes = Array.isArray(_groupNodes) ? _groupNodes : [_groupNodes];
const groupNode = find(row => {
return view(lensPath(['y:State', '@_closed']), row) === 'false'
}, groupNodes);
// This may have \n and other printable characters
const _groupName = view(lensPath(['y:NodeLabel', '#text']), groupNode);
const groupName = _groupName&& trimInside(_groupName) || "";
// console.warn(`getLabel > isCompoundState [yedNodeId, groupName]`, [yedNodeId, groupName])
return [yedNodeId, groupName]
}
else {
const _atomicStateName = view(lensPath(['y:ShapeNode', 'y:NodeLabel', '#text']), graphData) || view(lensPath(['y:GenericNode', 'y:NodeLabel', '#text']), graphData);
// This may have \n and other printable characters
const atomicStateName = _atomicStateName && trimInside(_atomicStateName) || "";
// console.warn(`getLabel > not(isCompoundState [yedNodeId, atomicStateName])`, [yedNodeId, atomicStateName], d6Key)
return [yedNodeId, atomicStateName]
}
};
const getChildren = graphObj => (graphObj.graph ? graphObj.graph.node : []);
const constructStateHierarchy = (label, children) => {
const [yedLabel, stateLabel] = label;
const _label = [stateLabel, yedLabel].join(SEP);
const isAtomicState = children => children && children.length === 0;
const isHistoryState = stateLabel => (stateLabel === "H" || stateLabel === "H*");
return stateLabel === YED_ENTRY_STATE
? {}
: isAtomicState(children)
? isHistoryState(stateLabel)
? {}
: {[_label]: ''}
: {[_label]: mergeAll(children)};
};
const constructStateYed2KinglyMap = (label, children) => {
// console.warn(`constructStateYed2KinglyMap > label`, label)
const [yedLabel, stateLabel] = label;
const newMap = yedLabel === void 0 ? {} : {[label[0]]: label[1]};
return mergeAll(concat(children, [newMap]));
};
const stateHierarchyLens = {
getLabel,
getChildren,
constructTree: constructStateHierarchy,
};
const stateYed2KinglyLens = {
getLabel,
getChildren,
constructTree: constructStateYed2KinglyMap,
};
/**
*
* @param {String} _yedEdgeLabel cf. grammar.
* @returns {{actionFactory: Array, event: String, guard: Array}[]} `actionFactory` and `guard` are arrays of action strings. For instance, "... / do this, do that" => actionFactory = ["do this", "do that"]
*/
function parseYedLabel(_yedEdgeLabel) {
// Parser for parsing edge labels
// It is a stateful object, so needs to be recreated every time
const parser = new nearley.Parser(nearley.Grammar.fromCompiled(yedEdgeLabelGrammar));
const yedEdgeLabel = _yedEdgeLabel && trimInside(_yedEdgeLabel).trim() || "";
try{parser.feed(yedEdgeLabel);} catch(e) {
console.error(e);
throw new Error(`parseYedLabel > parser.feed: String "${yedEdgeLabel}" fails parsing. \nPlease review the syntax rules for edge labels. \ncf. https://brucou.github.io/documentation/v1/tooling/graph_editing.html#Rules`)
}
// Two cases from the grammar:
// 1. multi-transitions label
// 2. mono-transition label
let arrTransitions = [];
const results = parser.results[0];
// console.warn(`results`, results)
if (Array.isArray(results)){
arrTransitions = arrTransitions.concat(results);
}
else {
if (results){
arrTransitions.push(results);
}
else {
arrTransitions.push({event: "", guard: [], actions: []});
}
}
return arrTransitions.map(transitionRecord => {
// console.warn(`transitionRecord `, transitionRecord)
const {event, guard, actions} = transitionRecord;
return {
actionFactory: actions.map(action => action.trim()),
event: event.trim(),
guard: guard.map(guard => guard.trim())
}
})
}
function aggregateEdgesPerFromEventKey(acc, yedEdge) {
const {edges: hashMap, events} = acc;
const from = view(lensPath(['@_source']), yedEdge).trim();
const to = view(lensPath(['@_target']), yedEdge).trim();
const yedEdgeLabel = getYedEdgeLabel(yedEdge);
const transitionsRecords = parseYedLabel(yedEdgeLabel);
transitionsRecords.forEach(transitionsRecord => {
const {actionFactory, event, guard} = transitionsRecord;
const fromEventKey = [from, event].join(SEP);
hashMap[fromEventKey] = hashMap[fromEventKey] || [];
hashMap[fromEventKey] = hashMap[fromEventKey].concat([
{predicate: guard.map(g => g.trim()), to: to.trim(), actionFactory: actionFactory.map(af => af.trim())},
]);
if (event) events.add(event)
});
return {edges: hashMap, events};
}
/**
* @modifies {errors}
* @param {Array} errors
* @param actionFactories actions passed by the API user
* @param guards guards passed by the API user
* @param Array<{{arrGuardsTargetActions, fromEventKey}}> edges
* @returns {Array} Array contains found errors, empty is no error found
*/
function checkForMissingFunctions(errors, {actionFactories, guards}, edges) {
// TODO: that could be refactored with applicative validation?
// TODO: remove some part, this is not used in slim (no error management)
forEachObjIndexed((arrGuardsTargetActions, fromEventKey) => {
const [yedFrom, _event] = fromEventKey.split(SEP);
// Anything but empty string is a valid state name
const isValidStateName = Boolean;
// For now events can be empty string or non-empty strings so always valid
const isValidEvent = T;
if (!isValidStateName(yedFrom)) errors.push({
when: `Checking that the name of the states figuring in the graph are valid`,
location: `checkForMissingFunctions > getKinglyTransitions`,
message: `Yed graph file mentions an invalid state |${yedFrom}|!`,
info: {state: yedFrom}
});
if (!isValidEvent(_event)) errors.push({
when: `Checking that the events figuring in the graph are valid`,
location: `checkForMissingFunctions > getKinglyTransitions`,
message: `Yed graph file mentions an invalid event |${_event}|!`,
info: {_event: _event}
});
arrGuardsTargetActions.every(guardsTargetActionRecord => {
// We do not check that guards and actions are functions, we want to allow for strings
const {predicate: _predicateList, to: yedTo, actionFactory: _actionFactoryList} = guardsTargetActionRecord;
const providedGuards = Object.keys(guards);
const expectedGuards = _predicateList.map(p => p.trim());
const providedActions = Object.keys(actionFactories);
const expectedActions = _actionFactoryList.map(af => af.trim());
const notNeededGuards = difference(providedGuards, expectedGuards);
const missingGuards = difference(expectedGuards, providedGuards);
const notNeededActions = difference(providedActions, expectedActions);
const missingActions = difference(expectedActions, providedActions);
if (!isValidStateName(yedTo)) errors.push({
when: `Checking that the name of the states figuring in the graph are valid`,
location: `checkForMissingFunctions > getKinglyTransitions`,
message: `Yed graph file mentions an invalid state |${yedTo}|!`,
info: {state: yedTo}
});
if (notNeededGuards.length>0) errors.push({
when: `Checking that the transitions figuring in the graph can be mapped to functions implementing them`,
location: `checkForMissingFunctions > getKinglyTransitions`,
message: `I found guards passed as parameters that do not match to a guard in the yed graph! Please remove them!`,
info: {notNeededGuards, guards, expectedGuards}
})
if (missingGuards.length>0) errors.push({
when: `Checking that the transitions figuring in the graph can be mapped to functions implementing them`,
location: `checkForMissingFunctions > getKinglyTransitions`,
message: `I found guards in the yed graph that cannot be matched to a JavaScript function! Please review the JavaScript guards that you passed.`,
info: {missingGuards, guards, expectedGuards}
})
if (notNeededActions.length>0) errors.push({
when: `Checking that the transitions figuring in the graph can be mapped to functions implementing them`,
location: `checkForMissingFunctions > getKinglyTransitions`,
message: `I found actions passed as parameters that do not match to an action in the yed graph! Please remove them!`,
info: {notNeededActions, actionFactories, expectedActions}
})
if (missingActions.length>0) errors.push({
when: `Checking that the transitions figuring in the graph can be mapped to functions implementing them`,
location: `checkForMissingFunctions > getKinglyTransitions`,
message: `I found actions in the yed graph that cannot be matched to a JavaScript function! Please review the JavaScript actions that you passed.`,
info: {missingActions, actionFactories, expectedActions}
})
});
}, edges);
return errors
}
function computeKinglyTransitionsFactory(stateYed2KinglyMap, edges, injected) {
const mapActionFactoryStrToActionFactoryFn =
injected && injected.mapActionFactoryStrToActionFactoryFn
|| defaultMapActionFactoryStrToActionFactoryFn;
const mapGuardStrToGuardFn =
injected && injected.mapGuardStrToGuardFn
|| defaultMapGuardStrToGuardFn;
// Transitions are computed by means of a function in which the mapping
// between actions and guards strings and the respective JavaScript functions
// is injected
return function getKinglyTransitions({actionFactories, guards}) {
let errors = [];
errors = checkForMissingFunctions(errors, {actionFactories, guards}, edges);
let transitions = [];
forEachObjIndexed((arrGuardsTargetActions, fromEventKey) => {
// Example:
// yedFrom: "n0::n0" ; userFrom: "entered by user" ; _from: "n0::n0[symbol]entered by user"
const [yedFrom, _event] = fromEventKey.split(SEP);
// console.warn(`forEachObjIndexed> [yedFrom, _event]`, [yedFrom, _event])
const _from = yedState2KinglyState(stateYed2KinglyMap, yedFrom);
const userFrom = stateYed2KinglyMap[yedFrom];
let from = _from;
let event = _event;
// Case: init transition
if (isInitialTransition(yedFrom, userFrom)) {
// console.warn(`isInitialTransition`, yedFrom, userFrom)
// rule <- No event allowed on initial states
// Not an unrecoverable error, as the event will be ignored
if (event.trim()){
errors.push({message: `getKinglyTransitions > No event allowed on initial states`})
}
// Case: top-level init transition
if (isTopLevelInitTransition(yedFrom, userFrom)) {
// console.warn(`isTopLevelInitTransition`, yedFrom, userFrom)
from = INIT_STATE;
event = INIT_EVENT;
} else {
// Case: non-top-level, i.e. compound state's init transition
// -> there is a parent to the origin node, that's the compound node we want
const fromParent = getYedParentNode(yedFrom);
from = [stateYed2KinglyMap[fromParent], fromParent].join(SEP);
event = INIT_EVENT;
}
}
if (isSimplifiableSyntax(arrGuardsTargetActions)) {
const {to: yedTo, actionFactory: actionFactoryStr} = arrGuardsTargetActions[0];
transitions.push({
from,
event,
to: computeKinglyDestinationState(stateYed2KinglyMap, yedTo),
action: mapActionFactoryStrToActionFactoryFn(actionFactories, actionFactoryStr),
});
} else {
transitions.push({
from,
event,
guards: arrGuardsTargetActions.map(guardsTargetActionRecord => {
const {predicate: predicateStr, to: yedTo, actionFactory: actionFactoryStr}
= guardsTargetActionRecord;
return {
predicate: mapGuardStrToGuardFn(guards, predicateStr),
to: computeKinglyDestinationState(stateYed2KinglyMap, yedTo),
action: mapActionFactoryStrToActionFactoryFn(actionFactories, actionFactoryStr),
};
}),
});
}
}, edges);
return {errors, transitions};
};
}
function computeTransitionsAndStatesFromXmlString(yedString) {
// Building the error accumulation capability
// Could thread this with applicative functors but keeping it simple and plain
let _errors = [];
const tryCatch = tryCatchFactory(_errors);
// Parse the xml string and traverse the xml tree to compute the state hierarchy.
// Kingly's state names will be made unique with concatenating yed node's name and user-given's
// node name. The name's unicity comes from yed naming including hierarchy information,
// e.g. n0::n0::n3 is a node two levels deep.
// As transitions in the .graphml file only use the yed node's name, we also keep a mapping of
// the correspondence between how yed labels node vs. how the user does
// Then we convert the transitions in the graphml, taking care of specific cases:
// - initial transitions
// - yed: node with label YED_ENTRY_STATE
// - history pseudo-states
// - yed: node with label YED_SHALLOW_HISTORY_STATE or YED_DEEP_HISTORY_STATE
const {graphml: graphObj} = tryCatch(parseGraphMlString, handleParseGraphMlStringErrors)(yedString);
if (_errors.length > 0) throw new Yed2KinglyConversionError(_errors);
const stateHierarchy = mapOverTree(stateHierarchyLens, x => x, graphObj)[SEP];
const stateYed2KinglyMap = mapOverTree(stateYed2KinglyLens, x => x, graphObj);
const yedEdges = graphObj.graph.edge;
// Kingly only admits one transition record per (from, event) couple
// Additionally, when there is no guard to check, a simplified transition format can be used
// i.e. {from, event, to, actionFactory}
// otherwise standard format: {from, event, guards : [{predicate, to, actionFactory}]} is used
// To prepare for deriving transitions, we use a hashmap which conflates all matching transitions
// in an array:
// edges ~~ {[from<|>event]: [...]}
const {edges, events} = yedEdges.reduce(
tryCatch(aggregateEdgesPerFromEventKey, handleAggregateEdgesPerFromEventKeyErrors),
{edges: {}, events: new Set()}
);
if (_errors.length > 0) throw new Yed2KinglyConversionError(_errors);
// Previously computed edges is traversed and converted into Kingly transitions
// 1. transitions with guards and actions assigned to their identifier
const transitionsWithoutGuardsActions = computeKinglyTransitionsFactory(
stateYed2KinglyMap,
edges,
{mapActionFactoryStrToActionFactoryFn: markFunctionStr, mapGuardStrToGuardFn: markFunctionStr}
)({actionFactories: {}, guards: {}}).transitions;
// 2. transitions with guards and actions assigned to a noop function
const transitionsWithFakeGuardsActions = computeKinglyTransitionsFactory(
stateYed2KinglyMap,
edges,
{mapActionFactoryStrToActionFactoryFn: markFunctionNoop, mapGuardStrToGuardFn: markGuardNoop}
)({actionFactories: {}, guards: {}}).transitions;
// 3. Factory to get the real transitions from the real actions and guards
const getKinglyTransitions = computeKinglyTransitionsFactory(stateYed2KinglyMap, edges);
return {
states: stateHierarchy,
stateYed2KinglyMap,
edges,
events: Array.from(events),
transitionsWithoutGuardsActions,
transitionsWithFakeGuardsActions,
getKinglyTransitions,
computeKinglyTransitionsFactory,
errors: _errors,
};
}
module.exports = {
computeTransitionsAndStatesFromXmlString,
};