defs
Version:
Static scope analysis and transpilation of ES6 block scoped const and let variables, to ES3.
681 lines (577 loc) • 23.3 kB
JavaScript
"use strict";
const assert = require("assert");
const is = require("simple-is");
const fmt = require("simple-fmt");
const stringmap = require("stringmap");
const stringset = require("stringset");
const alter = require("alter");
const traverse = require("ast-traverse");
const breakable = require("breakable");
const Scope = require("./scope");
const error = require("./error");
const getline = error.getline;
const options = require("./options");
const Stats = require("./stats");
const jshint_vars = require("./jshint_globals/vars.js");
function isConstLet(kind) {
return is.someof(kind, ["const", "let"]);
}
function isVarConstLet(kind) {
return is.someof(kind, ["var", "const", "let"]);
}
function isNonFunctionBlock(node) {
return node.type === "BlockStatement" && is.noneof(node.$parent.type, ["FunctionDeclaration", "FunctionExpression"]);
}
function isForWithConstLet(node) {
return node.type === "ForStatement" && node.init && node.init.type === "VariableDeclaration" && isConstLet(node.init.kind);
}
function isForInOfWithConstLet(node) {
return isForInOf(node) && node.left.type === "VariableDeclaration" && isConstLet(node.left.kind);
}
function isForInOf(node) {
return is.someof(node.type, ["ForInStatement", "ForOfStatement"]);
}
function isFunction(node) {
return is.someof(node.type, ["FunctionDeclaration", "FunctionExpression"]);
}
function isLoop(node) {
return is.someof(node.type, ["ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement"]);
}
function isReference(node) {
const parent = node.$parent;
return node.$refToScope ||
node.type === "Identifier" &&
!(parent.type === "VariableDeclarator" && parent.id === node) && // var|let|const $
!(parent.type === "MemberExpression" && parent.computed === false && parent.property === node) && // obj.$
!(parent.type === "Property" && parent.key === node) && // {$: ...}
!(parent.type === "LabeledStatement" && parent.label === node) && // $: ...
!(parent.type === "CatchClause" && parent.param === node) && // catch($)
!(isFunction(parent) && parent.id === node) && // function $(..
!(isFunction(parent) && is.someof(node, parent.params)) && // function f($)..
true;
}
function isLvalue(node) {
return isReference(node) &&
((node.$parent.type === "AssignmentExpression" && node.$parent.left === node) ||
(node.$parent.type === "UpdateExpression" && node.$parent.argument === node));
}
function createScopes(node, parent) {
assert(!node.$scope);
node.$parent = parent;
node.$scope = node.$parent ? node.$parent.$scope : null; // may be overridden
if (node.type === "Program") {
// Top-level program is a scope
// There's no block-scope under it
node.$scope = new Scope({
kind: "hoist",
node: node,
parent: null,
});
} else if (isFunction(node)) {
// Function is a scope, with params in it
// There's no block-scope under it
node.$scope = new Scope({
kind: "hoist",
node: node,
parent: node.$parent.$scope,
});
// function has a name
if (node.id) {
assert(node.id.type === "Identifier");
if (node.type === "FunctionDeclaration") {
// Function name goes in parent scope for declared functions
node.$parent.$scope.add(node.id.name, "fun", node.id, null);
} else if (node.type === "FunctionExpression") {
// Function name goes in function's scope for named function expressions
node.$scope.add(node.id.name, "fun", node.id, null);
} else {
assert(false);
}
}
node.params.forEach(function(param) {
node.$scope.add(param.name, "param", param, null);
});
} else if (node.type === "VariableDeclaration") {
// Variable declarations names goes in current scope
assert(isVarConstLet(node.kind));
node.declarations.forEach(function(declarator) {
assert(declarator.type === "VariableDeclarator");
const name = declarator.id.name;
if (options.disallowVars && node.kind === "var") {
error(getline(declarator), "var {0} is not allowed (use let or const)", name);
}
node.$scope.add(name, node.kind, declarator.id, declarator.range[1]);
});
} else if (isForWithConstLet(node) || isForInOfWithConstLet(node)) {
// For(In/Of) loop with const|let declaration is a scope, with declaration in it
// There may be a block-scope under it
node.$scope = new Scope({
kind: "block",
node: node,
parent: node.$parent.$scope,
});
} else if (isNonFunctionBlock(node)) {
// A block node is a scope unless parent is a function
node.$scope = new Scope({
kind: "block",
node: node,
parent: node.$parent.$scope,
});
} else if (node.type === "CatchClause") {
const identifier = node.param;
node.$scope = new Scope({
kind: "catch-block",
node: node,
parent: node.$parent.$scope,
});
node.$scope.add(identifier.name, "caught", identifier, null);
// All hoist-scope keeps track of which variables that are propagated through,
// i.e. an reference inside the scope points to a declaration outside the scope.
// This is used to mark "taint" the name since adding a new variable in the scope,
// with a propagated name, would change the meaning of the existing references.
//
// catch(e) is special because even though e is a variable in its own scope,
// we want to make sure that catch(e){let e} is never transformed to
// catch(e){var e} (but rather var e$0). For that reason we taint the use of e
// in the closest hoist-scope, i.e. where var e$0 belongs.
node.$scope.closestHoistScope().markPropagates(identifier.name);
}
}
function createTopScope(programScope, environments, globals) {
function inject(obj) {
for (let name in obj) {
const writeable = obj[name];
const kind = (writeable ? "var" : "const");
if (topScope.hasOwn(name)) {
topScope.remove(name);
}
topScope.add(name, kind, {loc: {start: {line: -1}}}, -1);
}
}
const topScope = new Scope({
kind: "hoist",
node: {},
parent: null,
});
const complementary = {
undefined: false,
Infinity: false,
console: false,
};
inject(complementary);
inject(jshint_vars.reservedVars);
inject(jshint_vars.ecmaIdentifiers);
if (environments) {
environments.forEach(function(env) {
if (!jshint_vars[env]) {
error(-1, 'environment "{0}" not found', env);
} else {
inject(jshint_vars[env]);
}
});
}
if (globals) {
inject(globals);
}
// link it in
programScope.parent = topScope;
topScope.children.push(programScope);
return topScope;
}
function setupReferences(ast, allIdentifiers, opts) {
const analyze = (is.own(opts, "analyze") ? opts.analyze : true);
function visit(node) {
if (!isReference(node)) {
return;
}
allIdentifiers.add(node.name);
const scope = node.$scope.lookup(node.name);
if (analyze && !scope && options.disallowUnknownReferences) {
error(getline(node), "reference to unknown global variable {0}", node.name);
}
// check const and let for referenced-before-declaration
if (analyze && scope && is.someof(scope.getKind(node.name), ["const", "let"])) {
const allowedFromPos = scope.getFromPos(node.name);
const referencedAtPos = node.range[0];
assert(is.finitenumber(allowedFromPos));
assert(is.finitenumber(referencedAtPos));
if (referencedAtPos < allowedFromPos) {
if (!node.$scope.hasFunctionScopeBetween(scope)) {
error(getline(node), "{0} is referenced before its declaration", node.name);
}
}
}
node.$refToScope = scope;
}
traverse(ast, {pre: visit});
}
// TODO for loops init and body props are parallel to each other but init scope is outer that of body
// TODO is this a problem?
function varify(ast, stats, allIdentifiers, changes) {
function unique(name) {
assert(allIdentifiers.has(name));
for (let cnt = 0; ; cnt++) {
const genName = name + "$" + String(cnt);
if (!allIdentifiers.has(genName)) {
return genName;
}
}
}
function renameDeclarations(node) {
if (node.type === "VariableDeclaration" && isConstLet(node.kind)) {
const hoistScope = node.$scope.closestHoistScope();
const origScope = node.$scope;
// text change const|let => var
changes.push({
start: node.range[0],
end: node.range[0] + node.kind.length,
str: "var",
});
node.declarations.forEach(function(declarator) {
assert(declarator.type === "VariableDeclarator");
const name = declarator.id.name;
stats.declarator(node.kind);
// rename if
// 1) name already exists in hoistScope, or
// 2) name is already propagated (passed) through hoistScope or manually tainted
const rename = (origScope !== hoistScope &&
(hoistScope.hasOwn(name) || hoistScope.doesPropagate(name)));
const newName = (rename ? unique(name) : name);
origScope.remove(name);
hoistScope.add(newName, "var", declarator.id, declarator.range[1]);
origScope.moves = origScope.moves || stringmap();
origScope.moves.set(name, {
name: newName,
scope: hoistScope,
});
allIdentifiers.add(newName);
if (newName !== name) {
stats.rename(name, newName, getline(declarator));
declarator.id.originalName = name;
declarator.id.name = newName;
// textchange var x => var x$1
changes.push({
start: declarator.id.range[0],
end: declarator.id.range[1],
str: newName,
});
}
});
// ast change const|let => var
node.kind = "var";
}
}
function renameReferences(node) {
if (!node.$refToScope) {
return;
}
const move = node.$refToScope.moves && node.$refToScope.moves.get(node.name);
if (!move) {
return;
}
node.$refToScope = move.scope;
if (node.name !== move.name) {
node.originalName = node.name;
node.name = move.name;
if (node.alterop) {
// node has no range because it is the result of another alter operation
let existingOp = null;
for (let i = 0; i < changes.length; i++) {
const op = changes[i];
if (op.node === node) {
existingOp = op;
break;
}
}
assert(existingOp);
// modify op
existingOp.str = move.name;
} else {
changes.push({
start: node.range[0],
end: node.range[1],
str: move.name,
});
}
}
}
traverse(ast, {pre: renameDeclarations});
traverse(ast, {pre: renameReferences});
ast.$scope.traverse({pre: function(scope) {
delete scope.moves;
}});
}
function detectLoopClosures(ast) {
traverse(ast, {pre: visit});
function detectIifyBodyBlockers(body, node) {
return breakable(function(brk) {
traverse(body, {pre: function(n) {
// if we hit an inner function of the loop body, don't traverse further
if (isFunction(n)) {
return false;
}
let err = true; // reset to false in else-statement below
const msg = "loop-variable {0} is captured by a loop-closure that can't be transformed due to use of {1} at line {2}";
if (n.type === "BreakStatement") {
error(getline(node), msg, node.name, "break", getline(n));
} else if (n.type === "ContinueStatement") {
error(getline(node), msg, node.name, "continue", getline(n));
} else if (n.type === "ReturnStatement") {
error(getline(node), msg, node.name, "return", getline(n));
} else if (n.type === "YieldExpression") {
error(getline(node), msg, node.name, "yield", getline(n));
} else if (n.type === "Identifier" && n.name === "arguments") {
error(getline(node), msg, node.name, "arguments", getline(n));
} else if (n.type === "VariableDeclaration" && n.kind === "var") {
error(getline(node), msg, node.name, "var", getline(n));
} else {
err = false;
}
if (err) {
brk(true); // break traversal
}
}});
return false;
});
}
function visit(node) {
// forbidden pattern:
// <any>* <loop> <non-fn>* <constlet-def> <any>* <fn> <any>* <constlet-ref>
var loopNode = null;
if (isReference(node) && node.$refToScope && isConstLet(node.$refToScope.getKind(node.name))) {
// traverse nodes up towards root from constlet-def
// if we hit a function (before a loop) - ok!
// if we hit a loop - maybe-ouch
// if we reach root - ok!
for (let n = node.$refToScope.node; ; ) {
if (isFunction(n)) {
// we're ok (function-local)
return;
} else if (isLoop(n)) {
loopNode = n;
// maybe not ok (between loop and function)
break;
}
n = n.$parent;
if (!n) {
// ok (reached root)
return;
}
}
assert(isLoop(loopNode));
// traverse scopes from reference-scope up towards definition-scope
// if we hit a function, ouch!
const defScope = node.$refToScope;
const generateIIFE = (options.loopClosures === "iife");
for (let s = node.$scope; s; s = s.parent) {
if (s === defScope) {
// we're ok
return;
} else if (isFunction(s.node)) {
// not ok (there's a function between the reference and definition)
// may be transformable via IIFE
if (!generateIIFE) {
const msg = "loop-variable {0} is captured by a loop-closure. Tried \"loopClosures\": \"iife\" in defs-config.json?";
return error(getline(node), msg, node.name);
}
// here be dragons
// for (let x = ..; .. ; ..) { (function(){x})() } is forbidden because of current
// spec and VM status
if (loopNode.type === "ForStatement" && defScope.node === loopNode) {
const declarationNode = defScope.getNode(node.name);
return error(getline(declarationNode), "Not yet specced ES6 feature. {0} is declared in for-loop header and then captured in loop closure", declarationNode.name);
}
// speak now or forever hold your peace
if (detectIifyBodyBlockers(loopNode.body, node)) {
// error already generated
return;
}
// mark loop for IIFE-insertion
loopNode.$iify = true;
}
}
}
}
}
function transformLoopClosures(root, ops, options) {
function insertOp(pos, str, node) {
const op = {
start: pos,
end: pos,
str: str,
}
if (node) {
op.node = node;
}
ops.push(op);
}
traverse(root, {pre: function(node) {
if (!node.$iify) {
return;
}
const hasBlock = (node.body.type === "BlockStatement");
const insertHead = (hasBlock ?
node.body.range[0] + 1 : // just after body {
node.body.range[0]); // just before existing expression
const insertFoot = (hasBlock ?
node.body.range[1] - 1 : // just before body }
node.body.range[1]); // just after existing expression
const forInName = (isForInOf(node) && node.left.declarations[0].id.name);;
const iifeHead = fmt("(function({0}){", forInName ? forInName : "");
const iifeTail = fmt("}).call(this{0});", forInName ? ", " + forInName : "");
// modify AST
const iifeFragment = options.parse(iifeHead + iifeTail);
const iifeExpressionStatement = iifeFragment.body[0];
const iifeBlockStatement = iifeExpressionStatement.expression.callee.object.body;
if (hasBlock) {
const forBlockStatement = node.body;
const tmp = forBlockStatement.body;
forBlockStatement.body = [iifeExpressionStatement];
iifeBlockStatement.body = tmp;
} else {
const tmp = node.body;
node.body = iifeExpressionStatement;
iifeBlockStatement.body[0] = tmp;
}
// create ops
insertOp(insertHead, iifeHead);
if (forInName) {
insertOp(insertFoot, "}).call(this, ");
const args = iifeExpressionStatement.expression.arguments;
const iifeArgumentIdentifier = args[1];
iifeArgumentIdentifier.alterop = true;
insertOp(insertFoot, forInName, iifeArgumentIdentifier);
insertOp(insertFoot, ");");
} else {
insertOp(insertFoot, iifeTail);
}
}});
}
function detectConstAssignment(ast) {
traverse(ast, {pre: function(node) {
if (isLvalue(node)) {
const scope = node.$scope.lookup(node.name);
if (scope && scope.getKind(node.name) === "const") {
error(getline(node), "can't assign to const variable {0}", node.name);
}
}
}});
}
function detectConstantLets(ast) {
traverse(ast, {pre: function(node) {
if (isLvalue(node)) {
const scope = node.$scope.lookup(node.name);
if (scope) {
scope.markWrite(node.name);
}
}
}});
ast.$scope.detectUnmodifiedLets();
}
function setupScopeAndReferences(root, opts) {
// setup scopes
traverse(root, {pre: createScopes});
const topScope = createTopScope(root.$scope, options.environments, options.globals);
// allIdentifiers contains all declared and referenced vars
// collect all declaration names (including those in topScope)
const allIdentifiers = stringset();
topScope.traverse({pre: function(scope) {
allIdentifiers.addMany(scope.decls.keys());
}});
// setup node.$refToScope, check for errors.
// also collects all referenced names to allIdentifiers
setupReferences(root, allIdentifiers, opts);
return allIdentifiers;
}
function cleanupTree(root) {
traverse(root, {pre: function(node) {
for (let prop in node) {
if (prop[0] === "$") {
delete node[prop];
}
}
}});
}
function run(src, config) {
// alter the options singleton with user configuration
for (let key in config) {
options[key] = config[key];
}
let parsed;
if (is.object(src)) {
if (!options.ast) {
return {
errors: [
"Can't produce string output when input is an AST. " +
"Did you forget to set options.ast = true?"
],
};
}
// Received an AST object as src, so no need to parse it.
parsed = src;
} else if (is.string(src)) {
try {
parsed = options.parse(src, {
loc: true,
range: true,
});
} catch (e) {
return {
errors: [
fmt("line {0} column {1}: Error during input file parsing\n{2}\n{3}",
e.lineNumber,
e.column,
src.split("\n")[e.lineNumber - 1],
fmt.repeat(" ", e.column - 1) + "^")
],
};
}
} else {
return {
errors: ["Input was neither an AST object nor a string."],
};
}
const ast = parsed;
// TODO detect unused variables (never read)
error.reset();
let allIdentifiers = setupScopeAndReferences(ast, {});
// static analysis passes
detectLoopClosures(ast);
detectConstAssignment(ast);
//detectConstantLets(ast);
const changes = [];
transformLoopClosures(ast, changes, options);
//ast.$scope.print(); process.exit(-1);
if (error.errors.length >= 1) {
return {
errors: error.errors,
};
}
if (changes.length > 0) {
cleanupTree(ast);
allIdentifiers = setupScopeAndReferences(ast, {analyze: false});
}
assert(error.errors.length === 0);
// change constlet declarations to var, renamed if needed
// varify modifies the scopes and AST accordingly and
// returns a list of change fragments (to use with alter)
const stats = new Stats();
varify(ast, stats, allIdentifiers, changes);
if (options.ast) {
// return the modified AST instead of src code
// get rid of all added $ properties first, such as $parent and $scope
cleanupTree(ast);
return {
stats: stats,
ast: ast,
};
} else {
// apply changes produced by varify and return the transformed src
const transformedSrc = alter(src, changes);
return {
stats: stats,
src: transformedSrc,
};
}
}
module.exports = run;