UNPKG

@scion-scxml/scxml

Version:

An implementation of SCXML in JavaScript.

462 lines (388 loc) 15.9 kB
//TODO: resolve data/@src and script/@src. either here, or in a separate module. //TODO: remove nodejs dependencies //TODO: decide on a friendly, portable interface to this module. streaming is possible, but maybe not very portable. var constants = require('../constants'); var sax = require("@scion-scxml/sax"), strict = true, // set to false for html-mode parser; function merge(o1,o2){ Object.keys(o2).forEach(function(k){ o1[k] = o2[k]; }); return o1; } function getNormalizedAttributeName(attr){ return attr.uri && attr.uri !== constants.SCXMLNS ? '{' + attr.uri + '}' + attr.local : attr.local; } function copyNsAttrObj(o){ var r = {}; Object.keys(o).forEach(function(k){ var attr = o[k]; r[getNormalizedAttributeName(attr)] = attr.value; }); return r; } function transform(xmlString){ parser = sax.parser(strict,{trim : false, xmlns : true}); var rootJson, currentJson, expressionAttributeCache, //we cache them because in sax-js attributes get processed before the nodes they're attached to, //and this is the only way we can capture their row/col numbers. //so when we finally find one, it gets popped off the stack. jsonStack = [], allTransitions = [], //we keep a reference to these so we can clean up the onTransition property later traversingInContentTagStack = [], cachedContentTagStartPosition, attributeValueLineColumnCache = {}, attributeValueEndLineColumnCache = {}, idCount = {}, cachedBeginCloseTagPosition, cachedOpenWakaPosition; function createInvokeJson(node){ var invoke = merge( { $line : cachedOpenWakaPosition.line, $column : cachedOpenWakaPosition.column, $type: node.local }, copyNsAttrObj(node.attributes)); //FIXME: for now, assume that document is valid, and currentJson is a state. //TODO: maybe verify that we are in a state currentJson.invokes = currentJson.invokes || []; currentJson.invokes.push(invoke); return currentJson = invoke; } function createActionJson(node){ var action = merge( { $line : cachedOpenWakaPosition.line, $column : cachedOpenWakaPosition.column, $type: getNormalizedAttributeName(node) }, copyNsAttrObj(node.attributes)); //console.log('action node',node); if(Array.isArray(currentJson)){ //this will be onExit and onEntry currentJson.push(action); }else if(currentJson.$type === 'scxml' && action.$type === 'script'){ //top-level script currentJson.rootScripts = currentJson.rootScripts || []; currentJson.rootScripts.push(action); }else{ //if it's any other action currentJson.actions = currentJson.actions || []; currentJson.actions.push(action); } return currentJson = action; } function createDataJson(node){ currentJson = merge({ $line : cachedOpenWakaPosition.line, $column : cachedOpenWakaPosition.column, $type : 'data' }, copyNsAttrObj(node.attributes)); return currentJson; } //TODO: avoid duplicate code function generateId(type){ if(idCount[type] === undefined) idCount[type] = 0; var count = idCount[type]++; return '$generated-' + type + '-' + count; } function createStateJson(node, isRoot){ var state = copyNsAttrObj(node.attributes); if(state.initial){ state.initial = state.initial.trim().split(/\s+/); if(state.initial.length === 1) state.initial = state.initial[0]; } if(state.type){ state.isDeep = state.type === 'deep' ? true : false; } //"state" is the default, so you don't need to explicitly write it if(node.local !== 'schema') state.$type = node.local; if(!state.id){ state.id = generateId(state.$type); } if(currentJson){ if(isRoot){ currentJson.rootState = state; delete currentJson.rootState.datamodel; //delete "datamodel" attribute on scxml root element }else { if(!currentJson.states){ currentJson.states = []; } currentJson.states.push(state); } } return currentJson = state; } function createTransitionJson(node){ var transition = copyNsAttrObj(node.attributes); //target can either be a string, an array (for multiple targets, e.g. targeting, or undefined if(transition.target){ //console.log('transition',transition); transition.target = transition.target.trim().split(/\s+/); if(transition.target.length === 1){ transition.target = transition.target[0]; } } if(currentJson){ if(!currentJson.transitions){ currentJson.transitions = []; } currentJson.transitions.push(transition); } allTransitions.push(transition); return currentJson = transition; } function createExpression(attrName, attrValue){ var posStart = attributeValueLineColumnCache[attrName], posEnd = attributeValueEndLineColumnCache[attrName]; return { $line : posStart.line, $column : posStart.column, expr : attrValue, $closeLine : posEnd.line, $closeColumn : posEnd.column }; } var tagActions = { "scxml": function(node){ if(!rootJson){ return rootJson = createStateJson(node, true); } else if(currentJson.$type === 'content'){ return createStateJson(node, true); } else { throw new Error('Unexpected scxml open tag'); } }, "initial": createStateJson, "history":createStateJson, "state":createStateJson, "parallel":createStateJson, "final":createStateJson, //transitions/action containers "transition" : createTransitionJson, "onentry":function(){ currentJson.onEntry = currentJson.onEntry || []; let block = []; currentJson.onEntry.push(block); currentJson = block; }, "onexit":function(){ currentJson.onExit = currentJson.onExit || []; let block = []; currentJson.onExit.push(block); currentJson = block; }, //actions "foreach" : createActionJson, "raise" : createActionJson, "log": createActionJson, "assign": function(node){ traversingInContentTagStack.push(node); cachedContentTagStartPosition = { line : parser.line, column : parser.column, position : parser.position }; createActionJson.apply(this, arguments); }, "script":createActionJson, "cancel":createActionJson, "send":createActionJson, //children of send/invoke "param": function(node){ currentJson.params = currentJson.params || []; var attr = copyNsAttrObj(node.attributes); currentJson.params.push(attr); currentJson = attr; }, "content":function(node){ //on invoke if(currentJson.$type === 'invoke'){ currentJson.content = { $line : parser.line, $column : parser.column, $type : 'content' }; currentJson = currentJson.content; }else{ traversingInContentTagStack.push(node); cachedContentTagStartPosition = { line : parser.line, column : parser.column, position : parser.position } } }, //these are treated a bit special - TODO: normalize/decide on a representation "if" : createActionJson, "elseif" : createActionJson, "else" : createActionJson, //data "datamodel":function(node){ //console.log('datamodel currentJson',currentJson); var datamodel = merge( { $line : cachedOpenWakaPosition.line, $column : cachedOpenWakaPosition.column, $type: node.local, declarations : [] }, copyNsAttrObj(node.attributes)); currentJson.datamodel = datamodel; currentJson = datamodel; }, "data":function(node){ //console.log('data currentJson',currentJson); traversingInContentTagStack.push(node); cachedContentTagStartPosition = { line : parser.line, column : parser.column, position : parser.position }; currentJson.declarations.push(createDataJson(node)); }, "invoke": createInvokeJson, "finalize": function(node){ return currentJson = currentJson.finalize = node; }, "donedata" : function(){ return currentJson = currentJson.donedata = {}; } }; expressionAttributeCache = {}; //TODO: put in onstart or something like that parser.onopentag = function (node) { //console.log("open tag",node.local, parser.line, parser.column, parser.position); if(traversingInContentTagStack.length){ traversingInContentTagStack.push(node); return; } if(tagActions[node.local]){ tagActions[node.local](node); jsonStack.push(currentJson); //console.log('current json now',currentJson,jsonStack.length); //merge in the current expression attribute cache merge(currentJson,expressionAttributeCache); expressionAttributeCache = {}; //clear the expression attribute cache } else { createActionJson(node); jsonStack.push(currentJson); merge(currentJson,expressionAttributeCache); expressionAttributeCache = {}; } //console.log('currentJson',currentJson,'jsonStack',jsonStack); }; var EXPRESSION_ATTRS = [ 'cond', 'array', 'location', 'namelist', 'idlocation']; parser.onopentagstart = function(){ //console.log('onopentagstart',tagName,parser.line,parser.column); }; parser.onopenwaka = function(){ //console.log('onopenwaka',parser.line,parser.column); cachedOpenWakaPosition = { line : parser.line, column : parser.column }; }; parser.onbeginclosetag = parser.onopentagslash = function(){ cachedBeginCloseTagPosition = { line : parser.line, column : parser.column }; }; parser.onclosetag = function(tag){ //console.log("close tag",tag); //console.log('currentJson', currentJson); currentJson.$closeLine = cachedBeginCloseTagPosition.line; currentJson.$closeColumn = cachedBeginCloseTagPosition.column; if(traversingInContentTagStack.length){ var lastTag = traversingInContentTagStack.pop(); if(lastTag.local !== tag){ throw new Error(`Mismatched start and end tags: start ${lastTag.local}, end ${tag}`); } if(!traversingInContentTagStack.length){ //we reached the bottom. capture the content string based on parser position let re = new RegExp(`</${tag}\\s*>`, 'g') let content = xmlString.substring(cachedContentTagStartPosition.position, parser.position); let match, lastMatch; match = re.exec(content); while (match) { lastMatch = match; match = re.exec(content); } if(lastMatch){ content = content.slice(0, lastMatch.index); if(!currentJson) throw new Error('No currentJson for tag ${tag}'); else currentJson.content = { $line : cachedContentTagStartPosition.line, $column : cachedContentTagStartPosition.column, content : content } //FIXME: make sure that we have an accurate start position. cachedContentTagStartPosition = null; } } } if(!traversingInContentTagStack.length){ jsonStack.pop(); currentJson = jsonStack[jsonStack.length - 1]; attributeValueLineColumnCache = {}; //console.log('currentJson',currentJson,'jsonStack',jsonStack); } }; //parser.onattributenameend = function(attrName){ // console.log('onattributenameend', attrName, parser.line, parser.column); //} parser.onattributevaluestart = function () { if(traversingInContentTagStack.length) return; //console.log('onattributevaluestart', parser.attribName, parser.line, parser.column); attributeValueLineColumnCache[parser.attribName] = { line : parser.line, column : parser.column }; } parser.onattributevalueend = function(){ //console.log('onattributevalueend', parser.attribName, parser.attribValue, parser.line, parser.column); attributeValueEndLineColumnCache[parser.attribName] = { line : parser.line, column : parser.column }; } parser.onattribute = function (attr) { if(traversingInContentTagStack.length) return; //console.log('onattribute ',attr, parser.line, parser.column, parser.position); //if attribute name ends with 'expr' or is one of the other ones enumerated above //then cache him and his position if( attr.name.match(/^.*expr$/) || EXPRESSION_ATTRS.indexOf(attr.name) > -1){ expressionAttributeCache[getNormalizedAttributeName(attr)] = createExpression(attr.name, attr.value); } }; parser.onerror = function (e) { // an error happened. throw e; }; parser.ontext = function (t) { if(traversingInContentTagStack.length) return; //console.log(t, traversingInContentTag, currentJson); //the only text we care about is that inside of <script> and <content> if(currentJson && currentJson.$type){ if(currentJson.$type === 'script'){ currentJson.content = t; //I don't think we need a separate expression for this w/ line/col mapping } } }; parser.oncdata = function (t) { currentJson.content = t; } parser.onend = function () { //do some scrubbing of root attributes delete rootJson.xmlns; //delete rootJson.type; //it can be useful to leave in 'type' === 'scxml' delete rootJson.version; if(typeof rootJson.datamodel === 'string') delete rootJson.datamodel; //this would happen if we have, e.g. state.datamodel === 'ecmascript' //change the property name of transition event to something nicer allTransitions.forEach(function(transition){ transition.onTransition = transition.actions; delete transition.actions; }); }; parser.write(xmlString).close(); return rootJson; } module.exports = transform;