c9-core/plugins/c9.ide.language.javascript/scope_analyzer.js

888 wiersze
28 KiB
JavaScript

/**
* JavaScript scope analysis module and warning reporter.
*
* This handler does a couple of things:
* 1. It does scope analysis and attaches a scope object to every variable, variable declaration and function declaration
* 2. It creates markers for undeclared variables
* 3. It creates markers for unused variables
* 4. It implements the local variable refactoring
*
* @depend ext/jslanguage/parse
*
* @copyright 2013, Ajax.org B.V.
*/
define(function(require, exports, module) {
var baseLanguageHandler = require('plugins/c9.ide.language/base_handler');
var completeUtil = require("plugins/c9.ide.language/complete_util");
var handler = module.exports = Object.create(baseLanguageHandler);
var JSResolver = require('plugins/c9.ide.language.javascript/JSResolver').JSResolver;
require("treehugger/traverse"); // add traversal functions to trees
var CALLBACK_METHODS = ["forEach", "map", "reduce", "filter", "every", "some",
"__defineGetter__", , "__defineSetter__"];
var CALLBACK_FUNCTIONS = ["require", "setTimeout", "setInterval"];
var PROPER = module.exports.PROPER = 80;
var MAYBE_PROPER = module.exports.MAYBE_PROPER = 1;
var NOT_PROPER = module.exports.NOT_PROPER = 0;
var KIND_EVENT = module.exports.KIND_EVENT = "event";
var KIND_PACKAGE = module.exports.KIND_PACKAGE = "package";
var KIND_HIDDEN = module.exports.KIND_HIDDEN = "hidden";
var KIND_DEFAULT = module.exports.KIND_DEFAULT = undefined;
var IN_CALLBACK_DEF = 1;
var IN_CALLBACK_BODY = 2;
var IN_CALLBACK_BODY_MAYBE = 3;
var lastValue;
var lastAST;
// Based on https://github.com/jshint/jshint/blob/master/jshint.js#L331
var GLOBALS = {
// Literals
"true": true,
"false": true,
"undefined": true,
"null": true,
"arguments": true,
"Infinity": true,
onmessage: true,
postMessage: true,
importScripts: true,
"continue": true,
"return": true,
"else": true,
// Browser
ArrayBuffer: true,
Attr: true,
Audio: true,
addEventListener: true,
applicationCache: true,
blur: true,
clearInterval: true,
clearTimeout: true,
close: true,
closed: true,
DataView: true,
defaultStatus: true,
document: true,
event: true,
FileReader: true,
Float32Array: true,
Float64Array: true,
FormData: true,
getComputedStyle: true,
Int16Array: true,
Int32Array: true,
Int8Array: true,
parent: true,
print: true,
removeEventListener: true,
resizeBy: true,
resizeTo: true,
self: true,
screen: true,
scroll: true,
scrollBy: true,
scrollTo: true,
sessionStorage: true,
setInterval: true,
setTimeout: true,
SharedWorker: true,
Uint16Array: true,
Uint32Array: true,
Uint8Array: true,
WebSocket: true,
window: true,
Worker: true,
XMLHttpRequest: true,
// Devel
alert: true,
confirm: true,
console: true,
prompt: true,
// require.js
define: true,
// node.js
__filename: true,
__dirname: true,
Buffer: true,
exports: true,
GLOBAL: true,
global: true,
module: true,
process: true,
require: true,
// Standard
Array: true,
Boolean: true,
Date: true,
decodeURI: true,
decodeURIComponent: true,
encodeURI: true,
encodeURIComponent: true,
Error: true,
'eval': true,
EvalError: true,
Function: true,
hasOwnProperty: true,
isFinite: true,
isNaN: true,
JSON: true,
Math: true,
Number: true,
Object: true,
parseInt: true,
parseFloat: true,
RangeError: true,
ReferenceError: true,
RegExp: true,
String: true,
requestAnimationFrame: true,
SyntaxError: true,
TypeError: true,
URIError: true,
// non-standard
escape: true,
unescape: true,
// meteor
Match: true,
MeteorSubscribeHandle: true,
Accounts: true,
Blaze: true,
DDP: true,
EJSON: true,
Meteor: true,
Mongo: true,
Tracker: true,
Assets: true,
App: true,
Plugin: true,
Package: true,
Npm: true,
Cordova: true,
currentUser: true,
loggingIn: true,
Template: true,
MethodInvocation: true,
Subscription: true,
CompileStep: true,
check: true,
Email: true,
HTTP: true,
ReactiveVar: true,
Session: true,
PackageAPI: true,
};
var KEYWORDS = [
"break",
"const",
"continue",
"delete",
"do",
"while",
"export",
"for",
"in",
"function",
"if",
"else",
"import",
"instanceof",
"new",
"return",
"switch",
"this",
"throw",
"try",
"catch",
"typeof",
"void",
"with",
"debugger"
];
/** @internal */
handler.GLOBALS = GLOBALS;
handler.addGlobals = function(globals) {
globals.forEach(function(g) {
GLOBALS[g] = true;
});
};
handler.handlesLanguage = function(language) {
// Note that we don't really support jsx here,
// but rather tolerate it...
return language === "javascript" || language === "jsx";
};
handler.getResolutions = function(value, ast, markers, callback) {
var resolver = new JSResolver(value, ast);
resolver.addResolutions(markers);
callback(markers);
};
handler.getMaxFileSizeSupported = function() {
// .25 of current base_handler default
return .25 * 10 * 1000 * 80;
};
/*
handler.hasResolution = function(value, ast, marker) {
if (marker.resolutions && marker.resolutions.length) {
return true;
}
var resolver = new JSResolver(value, ast);
return resolver.getType(marker);
};
*/
var scopeId = 0;
var Variable = module.exports.Variable = function Variable(declaration) {
this.declarations = [];
if (declaration)
this.declarations.push(declaration);
this.uses = [];
this.values = [];
};
Variable.prototype.addUse = function(node) {
this.uses.push(node);
};
Variable.prototype.addDeclaration = function(node) {
this.declarations.push(node);
};
Variable.prototype.markProperDeclaration = function(confidence) {
if (!confidence)
return;
else if (!this.properDeclarationConfidence)
this.properDeclarationConfidence = confidence;
else if (this.properDeclarationConfidence < PROPER)
this.properDeclarationConfidence += confidence;
};
Variable.prototype.isProperDeclaration = function() {
return this.properDeclarationConfidence > MAYBE_PROPER;
};
/**
* Implements Javascript's scoping mechanism using a hashmap with parent
* pointers.
*/
var Scope = module.exports.Scope = function Scope(parent) {
this.id = scopeId++;
this.parent = parent;
this.vars = {};
};
/**
* Declare a variable in the current scope
*/
Scope.prototype.declare = function(name, resolveNode, properDeclarationConfidence, kind) {
var result;
var vars = this.getVars(kind);
if (!vars['_' + name]) {
result = vars['_' + name] = new Variable(resolveNode);
}
else if (resolveNode) {
result = vars['_' + name];
result.addDeclaration(resolveNode);
}
if (result) {
result.markProperDeclaration(properDeclarationConfidence);
result.kind = kind;
}
return result;
};
Scope.prototype.declareAlias = function(kind, originalName, newName) {
var vars = this.getVars(kind);
vars["_" + newName] = vars["_" + originalName];
};
Scope.prototype.getVars = function(kind) {
if (kind)
return this.vars[kind] = this.vars[kind] || {};
else
return this.vars;
};
Scope.prototype.isDeclared = function(name) {
return !!this.get(name);
};
/**
* Get possible values of a variable
* @param name name of variable
* @return Variable instance
*/
Scope.prototype.get = function(name, kind) {
var vars = this.getVars(kind);
if (vars['_' + name])
return vars['_' + name];
else if (this.parent)
return this.parent.get(name, kind);
};
Scope.prototype.getVariableNames = function() {
return this.getNamesByKind(KIND_DEFAULT);
};
Scope.prototype.getNamesByKind = function(kind) {
var results = [];
var vars = this.getVars(kind);
for (var v in vars) {
if (vars.hasOwnProperty(v) && v !== KIND_HIDDEN && v !== KIND_PACKAGE)
results.push(v.slice(1));
}
if (this.parent) {
var namesFromParent = this.parent.getNamesByKind(kind);
for (var i = 0; i < namesFromParent.length; i++) {
results.push(namesFromParent[i]);
}
}
return results;
};
var SCOPE_ARRAY = Object.keys(GLOBALS).concat(KEYWORDS);
handler.getIdentifierRegex = function() {
// Allow slashes for package names
return (/[a-zA-Z_0-9\$\/]/);
};
handler.complete = function(doc, ast, pos, options, callback) {
if (!options.node || options.node.cons === "Var" || options.line[pos.column] === ".")
return callback();
var identifier = options.identifierPrefix;
var matches = completeUtil.findCompletions(identifier, SCOPE_ARRAY);
callback(matches.map(function(m) {
return {
name: m,
replaceText: m,
icon: null,
meta: "EcmaScript",
priority: 0,
isGeneric: true
};
}));
};
/**
* @param minimalAnalysis Only analyse bare basics, don't investigate errors.
* Most useful for inference analysis.
*/
handler.analyze = function(value, ast, callback, minimalAnalysis) {
var handler = this;
var markers = [];
if (minimalAnalysis && value === lastValue && lastAST == ast)
return callback();
lastValue = value;
lastAST = ast;
// Preclare variables (pre-declares, yo!)
function preDeclareHoisted(scope, node) {
node.traverseTopDown(
// var bla;
'VarDecl(x)', 'ConstDecl(x)', 'LetDecl(x)', function(b, node) {
node.setAnnotation("scope", scope);
scope.declare(b.x.value, b.x, PROPER);
return node;
},
// var bla = 10;
'VarDeclInit(x, e)', 'ConstDeclInit(x, e)', 'LetDeclInit(x, e)', function(b, node) {
node.setAnnotation("scope", scope);
scope.declare(b.x.value, b.x, PROPER);
},
// function bla(farg) { }
'Function(x, _, _)', function(b, node) {
node.setAnnotation("scope", scope);
if (b.x.value) {
scope.declare(b.x.value, b.x, PROPER);
}
return node;
},
'ImportDecl(_, x)', 'ImportBatchDecl(x)', function(b, node) {
if (b.x.cons !== "Var")
return node;
scope.declare(b.x[0].value, b.x[0], PROPER);
return node;
}
);
}
function scopeAnalyzer(scope, node, parentLocalVars, inCallback) {
preDeclareHoisted(scope, node);
node.setAnnotation("scope", scope);
function analyze(scope, node, inCallback) {
node.traverseTopDown(
'Assign(Var(x), e)', function(b, node) {
if (scope.isDeclared(b.x.value)) {
node[0].setAnnotation("scope", scope);
scope.get(b.x.value).addUse(node[0]);
}
analyze(scope, b.e, inCallback);
return node;
},
/*
'Var("this")', function(b, node) {
if (inCallback === IN_CALLBACK_BODY) {
markers.push({
pos: this.getPos(),
level: 'warning',
type: 'warning',
message: "Use of 'this' in callback function"
});
}
else if (inCallback === IN_CALLBACK_BODY_MAYBE) {
markers.push({
pos: this.getPos(),
level: 'info',
type: 'info',
message: "Use of 'this' in closure"
});
}
},
*/
'ImportDecl(_, x)', 'ImportBatchDecl(x)', function(b, node) {
return node;
},
'Var(x)', function(b, node) {
node.setAnnotation("scope", scope);
if (scope.isDeclared(b.x.value)) {
scope.get(b.x.value).addUse(node);
}
else if (b.x.value === "self"
&& !scope.isDeclared(b.x.value)
&& handler.isFeatureEnabled("undeclaredVars")) {
markers.push({
pos: this.getPos(),
level: 'warning',
type: 'warning',
message: "Use 'window.self' to refer to the 'self' global."
});
return;
}
return node;
},
'Function(x, fargs, body)', function(b, node) {
var newScope = new Scope(scope);
node.setAnnotation("localScope", newScope);
newScope.declare("this");
b.fargs.forEach(function(farg) {
farg.setAnnotation("scope", newScope);
newScope.declare(farg[0].value, farg);
});
var inBody = inCallback === IN_CALLBACK_DEF ? IN_CALLBACK_BODY : isCallback(node);
scopeAnalyzer(newScope, b.body, null, inBody);
return node;
},
'Arrow(fargs, body)', function(b, node) {
var newScope = new Scope(scope);
node.setAnnotation("localScope", newScope);
newScope.declare("this");
b.fargs.forEach(function(farg) {
farg.setAnnotation("scope", newScope);
newScope.declare(farg[0].value, farg);
});
scopeAnalyzer(newScope, b.body, null, inCallback);
return node;
},
'Catch(x, body)', function(b, node) {
var oldVar = scope.get(b.x.value);
// Temporarily override
scope.vars["_" + b.x.value] = new Variable(b.x);
scopeAnalyzer(scope, b.body, parentLocalVars, inCallback);
// Put back
scope.vars["_" + b.x.value] = oldVar;
return node;
},
/*
* Catches errors like these:
* if (err) callback(err);
* which in 99% of cases is wrong: a return should be added:
* if (err) return callback(err);
*/
'If(Var("err"), Call(fn, args), None())', function(b, node) {
// Check if the `err` variable is used somewhere in the function arguments.
if (b.args.collectTopDown('Var("err")').length > 0 &&
!b.fn.isMatch('PropAccess(Var("console"), _)') &&
!b.fn.isMatch('PropAccess(_, "log")'))
markers.push({
pos: b.fn.getPos(),
type: 'warning',
level: 'warning',
message: "Did you forget a 'return' here?"
});
},
'PropAccess(_, "lenght")', function(b, node) {
markers.push({
pos: node.getPos(),
type: 'warning',
level: 'warning',
message: "Did you mean 'length'?"
});
},
'Call(PropAccess(e1, "bind"), e2)', function(b) {
analyze(scope, b.e1, 0);
analyze(scope, b.e2, inCallback);
return this;
},
'Call(e, args)', function(b, node) {
analyze(scope, b.e, inCallback);
var newInCallback = inCallback || (isCallbackCall(node) ? IN_CALLBACK_DEF : 0);
analyze(scope, b.args, newInCallback);
return node;
},
'Block(_)', function(b, node) {
node.setAnnotation("scope", scope);
},
'For(e1, e2, e3, body)', function(b) {
analyze(scope, b.e1, inCallback);
analyze(scope, b.e2, inCallback);
analyze(scope, b.body, inCallback);
analyze(scope, b.e3, inCallback);
return node;
},
'ForIn(e1, e2, body)', 'ForOf(e1, e2, body)', function(b) {
analyze(scope, b.e2, inCallback);
analyze(scope, b.e1, inCallback);
analyze(scope, b.body, inCallback);
return node;
}
);
}
analyze(scope, node, inCallback);
}
if (ast) {
var rootScope = new Scope();
scopeAnalyzer(rootScope, ast);
addDefineWarnings(ast, markers);
}
return callback(markers);
};
function addDefineWarnings(ast, markers) {
var isArchitect;
var outerStrictNode;
ast.forEach(function(node) {
node.rewrite(
'String("use strict")', function(b, node) {
outerStrictNode = node;
},
'Call(Var("define"), [Function(_, _, body)])', function(b, node) {
b.body.forEach(function(node) {
if (outerStrictNode) {
markers.push({
pos: outerStrictNode.getPos(),
type: 'warning',
level: 'warning',
message: '"use strict" outside define()'
});
}
node.rewrite(
'Assign(PropAccess(Var("main"), "provides"),_)', function(b, node) {
isArchitect = true;
},
'Function("main", _, body)', function(b, node) {
if (!isArchitect)
return;
addCloud9PluginWarnings(b.body, markers);
}
);
});
}
);
});
}
function addCloud9PluginWarnings(body, markers) {
var isCoreSource = /plugins\/c9\./.test(handler.path);
var pluginVars = {};
var unloadFunction;
var unloadReference;
var maybeUnloadFunction;
body.forEach(function(node) {
node.rewrite(
'VarDecls(vars)', function(b, node) {
b.vars.forEach(function(v) {
v.rewrite(
'VarDecl(x)', 'LetDecl(x)',
'VarDeclInit(x, _)', 'LetDeclInit(x, _)',
function(b, node) {
pluginVars[b.x.value] = node;
}
);
});
},
'Call(PropAccess(Var("plugin"), "on"), [String("unload"), Function(_, _, fn)])', function(b, node) {
unloadFunction = b.fn;
},
'Call(PropAccess(Var("plugin"), "on"), [String("unload"), ref])', function(b, node) {
unloadReference = b.ref;
},
'Function("unload", _, fn)', function(b, node) {
maybeUnloadFunction = b.fn;
}
);
});
if (!unloadFunction && unloadReference && maybeUnloadFunction
&& unloadReference[0] && unloadReference[0].value === "unload")
unloadFunction = maybeUnloadFunction;
if (!unloadFunction) {
if (pluginVars.plugin && !unloadReference) {
markers.push({
pos: pluginVars.plugin.getPos(),
type: isCoreSource ? "info" : "warning",
message:
isCoreSource
? "No plugin.on(\"load\", function() {}) and/or plugin.on(\"unload\", function() {}) found"
: "Missing plugin.on(\"load\", function() {}) or plugin.on(\"unload\", function() {})"
});
}
return;
}
var mustUninitVars = {};
body.traverseTopDown(
'Assign(Var(x), _)', 'Call(Var(x), "push", _)', function(b, node) {
if (pluginVars[b.x.value])
mustUninitVars[b.x.value] = pluginVars[b.x.value];
}
);
unloadFunction.traverseTopDown(
'Var(x)', function(b, node) {
delete mustUninitVars[b.x.value];
}
);
for (var v in mustUninitVars) {
if (v === v.toUpperCase())
continue;
markers.push({
pos: mustUninitVars[v].getPos(),
type: isCoreSource ? "info" : "warning",
message: "Plugin state; please uninit/reset '" + v + "' in plugin unload function"
});
}
}
/**
* Determine if any callbacks in the current call
* should definitely get a warning for any uses of 'this'.
*/
var isCallbackCall = function(node) {
var result;
node.rewrite(
'Call(PropAccess(_, p), args)', function(b) {
if (b.args.length === 1 && CALLBACK_METHODS.indexOf(b.p.value) !== -1)
result = true;
},
'Call(Var(f), _)', function(b) {
if (CALLBACK_FUNCTIONS.indexOf(b.f.value) !== -1)
result = true;
}
);
return result;
};
/**
* Determine if the current callback should get a warning marker
* (IN_CALLBACK_BODY) for any uses of this, or just an info marker
* (IN_CALLBACK_BODY_MAYBE). Or, none at all (0).
*/
var isCallback = function(node) {
var parent = node.parent;
var parentParent = parent && parent.parent;
if (!parentParent)
return false;
try {
if (!parentParent.isMatch)
console.log("isCallback debug:", JSON.stringify(parentParent));
} catch (e) {
// Cannot print circular JSON in server-side tests
}
if (parent.isMatch('PropAccess(_, "call")')
|| parent.isMatch('PropAccess(_, "apply")')
|| parent.isMatch('PropAccess(_, "bind")')
|| !parentParent.isMatch('Call(_, _)')
|| (parentParent.cons === "Call" &&
parentParent[0].cons === "PropAccess" &&
parentParent[1].length > 1 &&
CALLBACK_METHODS.indexOf(parentParent[0][1].value) > -1)
)
return false;
var result = 0;
node.rewrite(
'Function(_, fargs, _)', function(b) {
if (b.fargs.length === 0 || b.fargs[0].cons !== 'FArg')
return result = IN_CALLBACK_BODY_MAYBE;
var name = b.fargs[0][0].value;
result = name === 'err' || name === 'error' || name === 'exc'
? IN_CALLBACK_BODY
: IN_CALLBACK_BODY_MAYBE;
}
);
return result;
};
handler.getRefactorings =
handler.highlightOccurrences = function(doc, fullAst, cursorPos, options, callback) {
if (!options.node)
return callback();
if (!fullAst.annos.scope) {
return handler.analyze(doc.getValue(), fullAst, function() {
handler.highlightOccurrences(doc, fullAst, cursorPos, options, callback);
}, true);
}
var markers = [];
var enableRefactorings = [];
function highlightVariable(v) {
if (!v)
return;
v.declarations.forEach(function(decl) {
if (decl.getPos())
markers.push({
pos: decl.getPos(),
type: 'occurrence_main'
});
});
v.uses.forEach(function(node) {
markers.push({
pos: node.getPos(),
type: 'occurrence_other'
});
});
}
options.node.rewrite(
'Var(x)', function(b, node) {
var scope = node.getAnnotation("scope");
if (!scope)
return;
var v = scope.get(b.x.value);
highlightVariable(v);
// Let's not enable renaming 'this' and only rename declared variables
if (b.x.value !== "this" && v)
enableRefactorings.push("rename");
},
'VarDeclInit(x, _)', 'ConstDeclInit(x, _)', 'LetDeclInit(x, _)', function(b) {
highlightVariable(this.getAnnotation("scope").get(b.x.value));
enableRefactorings.push("rename");
},
'VarDecl(x)', 'ConstDecl(x)', 'LetDecl(x)', function(b) {
highlightVariable(this.getAnnotation("scope").get(b.x.value));
enableRefactorings.push("rename");
},
'FArg(x)', function(b) {
highlightVariable(this.getAnnotation("scope").get(b.x.value));
enableRefactorings.push("rename");
},
'Function(x, _, _)', function(b, node) {
// Only for named functions
if (!b.x.value || !node.getAnnotation("scope"))
return;
highlightVariable(node.getAnnotation("scope").get(b.x.value));
enableRefactorings.push("rename");
}
);
callback({
markers: markers,
refactorings: enableRefactorings
});
};
handler.getRenamePositions = function(doc, fullAst, cursorPos, options, callback) {
var currentNode = options.node;
if (!fullAst || !currentNode)
return callback();
if (!fullAst.annos.scope) {
return handler.analyze(doc.getValue(), fullAst, function() {
handler.getRenamePositions(doc, fullAst, cursorPos, options, callback);
}, true);
}
var v;
var mainNode;
currentNode.rewrite(
'VarDeclInit(x, _)', 'ConstDeclInit(x, _)', 'LetDeclInit(x, _)', function(b, node) {
v = node.getAnnotation("scope").get(b.x.value);
mainNode = b.x;
},
'VarDecl(x)', 'ConstDecl(x)', 'LetDecl(x)', function(b, node) {
v = node.getAnnotation("scope").get(b.x.value);
mainNode = b.x;
},
'FArg(x)', function(b, node) {
v = node.getAnnotation("scope").get(b.x.value);
mainNode = node;
},
'Function(x, _, _)', function(b, node) {
if (!b.x.value)
return;
v = node.getAnnotation("scope").get(b.x.value);
mainNode = b.x;
},
'Var(x)', function(b, node) {
v = node.getAnnotation("scope").get(b.x.value);
mainNode = node;
}
);
// no mainnode can be found then invoke callback wo value because then we've got no clue
// what were doing
if (!mainNode) {
return callback();
}
var pos = mainNode.getPos();
var declarations = [];
var uses = [];
var length = pos.ec - pos.sc;
// if the annotation cant be found we will skip this to avoid null ref errors
v && v.declarations.forEach(function(node) {
if (node !== currentNode[0]) {
var pos = node.getPos();
declarations.push({ column: pos.sc, row: pos.sl });
}
});
v && v.uses.forEach(function(node) {
if (node !== currentNode) {
var pos = node.getPos();
uses.push({ column: pos.sc, row: pos.sl });
}
});
callback({
length: length,
pos: {
row: pos.sl,
column: pos.sc
},
others: declarations.concat(uses),
declarations: declarations,
uses: uses
});
};
});