@scion-scxml/scxml
Version:
An implementation of SCXML in JavaScript.
534 lines (454 loc) • 17.4 kB
JavaScript
;
var esprima = require('esprima');
var escodegen = require('escodegen');
var fileUtils = require('./file-utils');
var assert = require('assert');
var systemVariables = ["_event", "_sessionid", "_name", "_ioprocessors", "_x"];
var scJsonAnalyzer = {
analyze: function(scJson, docUrl, context, done) {
var changes = [],
syntaxErrors = [],
asyncCount = 0,
waitingForAsync = false,
reportCompileErrors = (context.reportAllErrors === true),
datamodelAccumulator = [];
//traverse and accumulate datamodel declarations
//traverse, and find all assign/@location
//send/@namelist, donedata/@namelist
//we could also traverse inside of js expressions, and look for identifiers. but we would then also need to accumulate variable declarations.
function traverseAndAccumulateDatamodelDeclarations(state){
if(state.datamodel) datamodelAccumulator.push.apply(datamodelAccumulator, state.datamodel.declarations);
if(state.states) state.states.forEach(function(substate){ traverseAndAccumulateDatamodelDeclarations(substate); });
}
function processState(state) {
if(state.datamodel) processActions(state.datamodel, 'declarations');
if(state.onExit) processActions(state, 'onExit');
if(state.onEntry) processActions(state, 'onEntry');
if(state.donedata) processActions(state, 'donedata',state);
if(state.transitions) {
processActions(state, 'transitions', state);
state.transitions.forEach(function(transition){
if(transition.onTransition) {
processActions(transition, 'onTransition');
}
});
}
if(state.rootScripts) {
processActions(state, 'rootScripts');
}
if(state.states) state.states.forEach(function(substate){ processState(substate); });
}
function processActions(actionContainer, name, state){
let blocks = actionContainer[name];
if(Array.isArray(blocks)) {
blocks.forEach(function(block, i) {
if(Array.isArray(block)){
block.forEach(function(action, j){
checkAction(block, j, block[j].$type || name, state);
if(block[j].actions) processActions(block[j], 'actions');
});
} else {
checkAction(blocks, i, blocks[i].$type || name, state);
if(blocks[i].actions) processActions(blocks[i], 'actions');
}
});
} else {
checkAction(actionContainer, name, name, state);
}
}
function checkAction(action, propertyName, $type, state) {
if(actionTags[$type]) {
var errors = actionTags[$type](action[propertyName], function (errors, override) {
if(override) {
handleError(action, propertyName, errors, $type, state, override);
} else if(errors.length > 0) {
handleError(action, propertyName, errors, $type, state);
}
asyncDone();
});
if(errors) {
if(errors.override) {
handleError(action, propertyName, errors.errors, $type, state, errors.override);
} else if(errors.length > 0) {
handleError(action, propertyName, errors, $type, state);
}
}
}
}
function checkLocationExistsInDatamodel(node){
let syntaxTree = esprima.parse(node.location.expr, {});
let ids = datamodelAccumulator.map( data => data.id )
//retrieve the first identifier:
//we expect top-level ExpressionStatement
//recurse 0-to-many MemberExpression all the way down
//until we hit the inner-most identifier
//then peel off the name property
assert.equal(syntaxTree.body[0].type, "ExpressionStatement");
let o = syntaxTree.body[0].expression;
while(o.type !== "Identifier"){
assert.equal(o.type, "MemberExpression");
o = o.object;
}
assert.equal(o.type, "Identifier");
let id = o.name;
return ids.indexOf(id) === -1;
}
function convertLocalVariableDeclarationsToAssignmentExpression(scriptNode){
let script = scriptNode.content;
//traverse syntax tree, and look for variable declarations
let arraysToSplice = [];
try {
let syntaxTree = esprima.parse(script, { loc: true, range: true, comment: true });
traverse(syntaxTree,'body');
if(arraysToSplice.length){
arraysToSplice.forEach(function(arr){
let parentNode = arr[0], nodeToReplace = arr[1];
let i = parentNode.indexOf(nodeToReplace);
parentNode.splice.apply(parentNode,
[i, 1].concat(
nodeToReplace.declarations
.map(function(declaration){
//TODO: does expression statement need line/col?
return declaration.init &&
datamodelAccumulator.map( data => data.id ).indexOf(declaration.id.name) > -1 ?
{
"type": "ExpressionStatement",
"expression": {
"type": "AssignmentExpression",
"operator": "=",
"left": declaration.id,
"right": declaration.init,
"range": declaration.range,
"loc": declaration.loc
}
} :
{
"type": "VariableDeclaration",
"kind": nodeToReplace.kind,
"declarations": [ declaration ]
};
})
)
);
});
let x = escodegen.generate(syntaxTree,
{
sourceMap: 'x',
sourceMapWithCode: true
}
);
scriptNode.content = x.code;
scriptNode.rawSourceMap = x.map.toJSON();
}
} catch(e){
handleError(scriptNode, 'content', [e], 'script', null, true);
}
function traverse(parentNode,property){
const node = parentNode[property];
//VariableDeclaration.declarations[VariableDeclarator] -> ExpressionStatement.expression.expressions[AssignmentExpression]
if(node.type === 'VariableDeclaration'){
//prepare replacement data structure
if(node.declarations.some(function(declaration){
return datamodelAccumulator.map( data => data.id ).indexOf(declaration.id.name) > -1;
})){
//remember this position, so that we can replace
arraysToSplice.push([parentNode, node]);
}
} else if(node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression') {
//allow variable shadowing inside of functions
//do not traverse inside function declarations, as this creates a new scope
return;
} else {
Object.keys(node).forEach(function(key){
let o = node[key];
if(Array.isArray(o)){
for(let i = 0; i < o.length; i++){
traverse(o, i);
}
} else if(typeof o === 'object' && o && o.type){
traverse(node, key);
} else {
//ignore
}
});
}
}
}
var actionTags = {
'donedata' : function(node){
if(node.expr){ //content
return validateJavascriptExpression(node.expr, true);
}else if(node.params){ //params
return node.params.map(function(param){
if(param.location && checkLocationExistsInDatamodel(param)){
//return error
return ['Param references location not declared in the datamodel:' + param.location.expr];
}
return validateJavascriptExpression(param.expr, true);
}).reduce(function(a,b){return a.concat(b)},[]);
} //TODO: namelist?
},
'data': function (node, done) {
//If there is an external file and
//src attribute starting exactly with "file:"
if(node.src && node.src.indexOf('file:') === 0) {
asyncStarted();
getFileContents(node.src.substring(5), function (error, content) {
if(error) {
done([error]);
} else {
delete node.src;
node.content = { $column: node.$column,
$line: node.$line,
content: normalizeWhitespace(content)
};
done(null, node);
}
});
} else if(node.content) {
var errors = validateJavascriptAssignment(node.id, node.content);
node.content.content = normalizeWhitespace(node.content.content);
if(errors.length > 0) {
//node.expr = node.content;
//delete node.content;
//TODO: handle this case
}
return { override: node, errors: errors };
} else {
return validateJavascriptAssignment(node.id, node.expr);
}
},
'assign': function (node) {
if(checkLocationExistsInDatamodel(node) && systemVariables.indexOf(node.location.expr) === -1){
//return error
return ['Assignment to location not declared in the datamodel:' + node.location.expr];
}
if(node.location && node.expr) {
return validateJavascriptAssignment(node.location, node.expr);
}
return [];
},
'transitions': function (node) {
if(node.cond) {
// return validateJavascriptCondition(node.cond);
var errors = validateJavascriptCondition(node.cond);
if(errors.length) {
//Assume illegal booleans as false, send override
//https://github.com/jbeard4/scxml-test-framework/blob/2.0.0/test/w3c-ecma/test309.txml.scxml
node.cond.expr = 'false';
return { override: node, errors: errors };
}
}
return [];
},
'if': function (node) {
return validateJavascriptCondition(node.cond);
},
'ifelse': function (node) {
return validateJavascriptCondition(node.cond);
},
'script': function (node) {
if(node.src) {
// DO NOT inline the external script here. that MUST be done
// each time the parent document is requested
} else if(node.content) {
convertLocalVariableDeclarationsToAssignmentExpression(node);
return validateArbitraryJavascript(node.content);
}
},
'log': function (node) {
if(node.expr) {
return validateJavascriptExpression(node.expr, true);
}
return [];
},
'send': function (node) {
if(node.$type) {
return validateJavascriptExpression(node.expr);
}
return [];
},
'foreach': function (node) {
var errors = [];
var results;
if(node.item) {
results = validateJavascriptIdentifier(node.item);
if(results && results.length > 0)
{
errors = errors.concat(results);
}
}
if(node.index) {
results = validateJavascriptIdentifier(node.index);
if(results && results.length > 0)
{
errors = errors.concat(results);
}
}
if(node.array) {
results = validateJavascriptExpression(node.array);
if(results && results.length > 0)
{
errors = errors.concat(results);
}
}
return errors;
}
};
function validateJavascriptAssignment(leftHand, rightHand) {
var errors = [];
var leftHandCheck = validateArbitraryJavascript(leftHand);
var rightHandCheck = validateArbitraryJavascript(extractJavascript(leftHand) + ' = ' + extractJavascript(rightHand));
if(leftHandCheck.length) {
errors.push(leftHandCheck);
} else if(rightHandCheck.length) {
errors.push(rightHandCheck);
} else if(systemVariables.indexOf(extractJavascript(leftHand)) !== -1) {
errors.push('You can\'t change system variables: ' + leftHand);
}
return errors;
}
function validateJavascriptCondition(condition) {
return validateArbitraryJavascript(condition);
}
function validateJavascriptExpression (js, allowLiteral) {
return validateArbitraryJavascript(js, allowLiteral);
}
function validateJavascriptIdentifier (js) {
js = extractJavascript(js);
var errors = validateArbitraryJavascript(js);
if(errors.length) return errors;
var syntaxTree = esprima.parse(js, {});
if(syntaxTree.body[0].expression.type !== 'Identifier') {
return ['Illegal identifier: ' + js];
}
}
function validateArbitraryJavascript (js, allowLiteral) {
js = extractJavascript(js);
if (allowLiteral) {
js = '_lhs = ' + js;
}
var errors = [];
try {
var syntaxTree = esprima.parse(js, {});
traverseSyntaxTree(syntaxTree, errors);
} catch (e) {
errors.push(e.message);
}
return errors;
}
var treeTypes = {
"AssignmentExpression": function(tree, errors) {
//Check if assignee is a system variable in for statement
if(tree.init && tree.init.left && systemVariables.indexOf(tree.init.left.name) !== -1) {
errors.push('You can\'t change system variables: ' + tree.init.left.name);
}
//Check if assignee is a system variable in expressions
if(tree.expression && tree.expression.left && systemVariables.indexOf(tree.expression.left.name) !== -1) {
errors.push('You can\'t change system variables: ' + tree.expression.left.name);
}
}
};
function traverseSyntaxTree(tree, errors) {
Object.keys(tree).forEach(function(i){
if (tree[i] && typeof(tree[i]) === 'object') {
if (tree[i].type && treeTypes[tree[i].type]) {
treeTypes[tree[i].type](tree, errors);
}
//Go deeper into the child nodes
traverseSyntaxTree(tree[i], errors);
}
});
}
function getFileContents(filePath, done) {
//docUrl and context are coming from top function.
fileUtils.read(filePath, docUrl, context, function (fileContent) {
done(fileContent.error, fileContent.content);
});
}
function handleError (node, property, errors, $type, state, override) {
if (reportCompileErrors && errors && errors.length) {
var n = node[property];
syntaxErrors.push({
tagname: n.$type,
line: n.$line,
column: n.$column,
reason: errors.join('; ')
});
}
let message = errors ? errors.join(', ') : '';
var errorNode = {
$line : node[property].$line,
$column : node[property].$column,
$type: 'script',
content : `throw new Error(${JSON.stringify(message)})`
};
changes.push({
old: node,
prop: property,
$type: $type,
new: override,
state: state,
error: errorNode
});
}
function extractJavascript (attribute) {
//Just a workaround for esprima parsing.
if (typeof(attribute) === 'object') {
attribute = attribute.expr;
}
return attribute;
}
function normalizeWhitespace (str) {
return str.replace(/^\s+|\s+$|\s+(?=\s)/g, '').replace(/\s/g, " ");
}
function commitChanges (scJson) {
changes.forEach(function (change) {
if(change.$type === 'donedata') {
delete change.state.donedata;
change.state.onEntry = change.state.onEntry || [];
change.state.onEntry.push(change.error);
}else if(change.$type === 'data' && !change.new) {
delete scJson.datamodel;
scJson.onEntry = [ change.new || change.error ];
} else if(change.$type === 'script' && !change.new && scJson.rootScripts) {
delete scJson.rootScripts;
scJson.onEntry = [ change.error ];
} else if(change.$type === 'transitions') {
if(!change.state.onEntry) change.state.onEntry = [];
change.state.onEntry.push(change.error);
change.old[change.prop] = change.new;
} else {
change.old[change.prop] = change.new || change.error;
}
});
}
function asyncStarted () {
asyncCount++;
}
function asyncDone () {
asyncCount--;
//If we are only waiting for async processes
if(waitingForAsync && asyncCount === 0) {
completeAnalysis();
}
}
function completeAnalysis () {
if (syntaxErrors.length) {
scJson = undefined;
} else {
commitChanges(scJson);
}
done({ scJson: scJson, errors: syntaxErrors });
}
traverseAndAccumulateDatamodelDeclarations(scJson);
processState(scJson, 'scJson');
if(asyncCount === 0) {
completeAnalysis();
} else {
//Wait for async processes to end
waitingForAsync = true;
}
}
};
module.exports = scJsonAnalyzer;