diff options
Diffstat (limited to 'tools/lint/eslint/eslint-plugin-mozilla/lib/helpers.js')
-rw-r--r-- | tools/lint/eslint/eslint-plugin-mozilla/lib/helpers.js | 524 |
1 files changed, 524 insertions, 0 deletions
diff --git a/tools/lint/eslint/eslint-plugin-mozilla/lib/helpers.js b/tools/lint/eslint/eslint-plugin-mozilla/lib/helpers.js new file mode 100644 index 000000000..50e00ab97 --- /dev/null +++ b/tools/lint/eslint/eslint-plugin-mozilla/lib/helpers.js @@ -0,0 +1,524 @@ +/** + * @fileoverview A collection of helper functions. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +"use strict"; + +var escope = require("escope"); +var espree = require("espree"); +var estraverse = require("estraverse"); +var path = require("path"); +var fs = require("fs"); +var ini = require("ini-parser"); + +var modules = null; +var directoryManifests = new Map(); + +var definitions = [ + /^loader\.lazyGetter\(this, "(\w+)"/, + /^loader\.lazyImporter\(this, "(\w+)"/, + /^loader\.lazyServiceGetter\(this, "(\w+)"/, + /^loader\.lazyRequireGetter\(this, "(\w+)"/, + /^XPCOMUtils\.defineLazyGetter\(this, "(\w+)"/, + /^XPCOMUtils\.defineLazyModuleGetter\(this, "(\w+)"/, + /^XPCOMUtils\.defineLazyServiceGetter\(this, "(\w+)"/, + /^XPCOMUtils\.defineConstant\(this, "(\w+)"/, + /^DevToolsUtils\.defineLazyModuleGetter\(this, "(\w+)"/, + /^DevToolsUtils\.defineLazyGetter\(this, "(\w+)"/, + /^Object\.defineProperty\(this, "(\w+)"/, + /^Reflect\.defineProperty\(this, "(\w+)"/, + /^this\.__defineGetter__\("(\w+)"/, + /^this\.(\w+) =/ +]; + +var imports = [ + /^(?:Cu|Components\.utils)\.import\(".*\/((.*?)\.jsm?)"(?:, this)?\)/, +]; + +module.exports = { + /** + * Gets the abstract syntax tree (AST) of the JavaScript source code contained + * in sourceText. + * + * @param {String} sourceText + * Text containing valid JavaScript. + * + * @return {Object} + * The resulting AST. + */ + getAST: function(sourceText) { + // Use a permissive config file to allow parsing of anything that Espree + // can parse. + var config = this.getPermissiveConfig(); + + return espree.parse(sourceText, config); + }, + + /** + * A simplistic conversion of some AST nodes to a standard string form. + * + * @param {Object} node + * The AST node to convert. + * + * @return {String} + * The JS source for the node. + */ + getASTSource: function(node) { + switch (node.type) { + case "MemberExpression": + if (node.computed) + throw new Error("getASTSource unsupported computed MemberExpression"); + return this.getASTSource(node.object) + "." + this.getASTSource(node.property); + case "ThisExpression": + return "this"; + case "Identifier": + return node.name; + case "Literal": + return JSON.stringify(node.value); + case "CallExpression": + var args = node.arguments.map(a => this.getASTSource(a)).join(", "); + return this.getASTSource(node.callee) + "(" + args + ")"; + case "ObjectExpression": + return "{}"; + case "ExpressionStatement": + return this.getASTSource(node.expression) + ";"; + case "FunctionExpression": + return "function() {}"; + case "ArrowFunctionExpression": + return "() => {}"; + case "AssignmentExpression": + return this.getASTSource(node.left) + " = " + this.getASTSource(node.right); + default: + throw new Error("getASTSource unsupported node type: " + node.type); + } + }, + + /** + * This walks an AST in a manner similar to ESLint passing node and comment + * events to the listener. The listener is expected to be a simple function + * which accepts node type, node and parents arguments. + * + * @param {Object} ast + * The AST to walk. + * @param {Function} listener + * A callback function to call for the nodes. Passed three arguments, + * event type, node and an array of parent nodes for the current node. + */ + walkAST(ast, listener) { + let parents = []; + + let seenComments = new Set(); + function sendCommentEvents(comments) { + if (!comments) { + return; + } + + for (let comment of comments) { + if (seenComments.has(comment)) { + return; + } + seenComments.add(comment); + + listener(comment.type + "Comment", comment, parents); + } + } + + estraverse.traverse(ast, { + enter(node, parent) { + // Comments are held in node.comments for empty programs + let leadingComments = node.leadingComments; + if (node.type === "Program" && node.body.length == 0) { + leadingComments = node.comments; + } + + sendCommentEvents(leadingComments); + listener(node.type, node, parents); + sendCommentEvents(node.trailingComments); + + parents.push(node); + }, + + leave(node, parent) { + // TODO send comment exit events + listener(node.type + ":exit", node, parents); + + if (parents.length == 0) { + throw new Error("Left more nodes than entered."); + } + parents.pop(); + } + }); + if (parents.length) { + throw new Error("Entered more nodes than left."); + } + }, + + /** + * Attempts to convert an ExpressionStatement to likely global variable + * definitions. + * + * @param {Object} node + * The AST node to convert. + * @param {boolean} isGlobal + * True if the current node is in the global scope. + * @param {String} repository + * The root of the repository. + * + * @return {Array} + * An array of variable names defined. + */ + convertExpressionToGlobals: function(node, isGlobal, repository) { + if (!modules) { + modules = require(path.join(repository, "tools", "lint", "eslint", "modules.json")); + } + + try { + var source = this.getASTSource(node); + } + catch (e) { + return []; + } + + for (var reg of definitions) { + var match = source.match(reg); + if (match) { + // Must be in the global scope + if (!isGlobal) { + return []; + } + + return [match[1]]; + } + } + + for (reg of imports) { + var match = source.match(reg); + if (match) { + // The two argument form is only acceptable in the global scope + if (node.expression.arguments.length > 1 && !isGlobal) { + return []; + } + + if (match[1] in modules) { + return modules[match[1]]; + } + + return [match[2]]; + } + } + + return []; + }, + + /** + * Add a variable to the current scope. + * HACK: This relies on eslint internals so it could break at any time. + * + * @param {String} name + * The variable name to add to the scope. + * @param {ASTScope} scope + * The scope to add to. + * @param {boolean} writable + * Whether the global can be overwritten. + */ + addVarToScope: function(name, scope, writable) { + scope.__defineGeneric(name, scope.set, scope.variables, null, null); + + let variable = scope.set.get(name); + variable.eslintExplicitGlobal = false; + variable.writeable = writable; + + // Walk to the global scope which holds all undeclared variables. + while (scope.type != "global") { + scope = scope.upper; + } + + // "through" contains all references with no found definition. + scope.through = scope.through.filter(function(reference) { + if (reference.identifier.name != name) { + return true; + } + + // Links the variable and the reference. + // And this reference is removed from `Scope#through`. + reference.resolved = variable; + variable.references.push(reference); + return false; + }); + }, + + /** + * Adds a set of globals to a scope. + * + * @param {Array} globalVars + * An array of global variable names. + * @param {ASTScope} scope + * The scope. + */ + addGlobals: function(globalVars, scope) { + globalVars.forEach(v => this.addVarToScope(v.name, scope, v.writable)); + }, + + /** + * To allow espree to parse almost any JavaScript we need as many features as + * possible turned on. This method returns that config. + * + * @return {Object} + * Espree compatible permissive config. + */ + getPermissiveConfig: function() { + return { + range: true, + loc: true, + comment: true, + attachComment: true, + ecmaVersion: 8, + sourceType: "script", + ecmaFeatures: { + experimentalObjectRestSpread: true, + globalReturn: true, + } + }; + }, + + /** + * Check whether the context is the global scope. + * + * @param {Array} ancestors + * The parents of the current node. + * + * @return {Boolean} + * True or false + */ + getIsGlobalScope: function(ancestors) { + for (let parent of ancestors) { + if (parent.type == "FunctionExpression" || + parent.type == "FunctionDeclaration") { + return false; + } + } + return true; + }, + + /** + * Check whether we might be in a test head file. + * + * @param {RuleContext} scope + * You should pass this from within a rule + * e.g. helpers.getIsHeadFile(this) + * + * @return {Boolean} + * True or false + */ + getIsHeadFile: function(scope) { + var pathAndFilename = this.cleanUpPath(scope.getFilename()); + + return /.*[\\/]head(_.+)?\.js$/.test(pathAndFilename); + }, + + /** + * Gets the head files for a potential test file + * + * @param {RuleContext} scope + * You should pass this from within a rule + * e.g. helpers.getIsHeadFile(this) + * + * @return {String[]} + * Paths to head files to load for the test + */ + getTestHeadFiles: function(scope) { + if (!this.getIsTest(scope)) { + return []; + } + + let filepath = this.cleanUpPath(scope.getFilename()); + let dir = path.dirname(filepath); + + let names = fs.readdirSync(dir) + .filter(name => name.startsWith("head") && name.endsWith(".js")) + .map(name => path.join(dir, name)); + return names; + }, + + /** + * Gets all the test manifest data for a directory + * + * @param {String} dir + * The directory + * + * @return {Array} + * An array of objects with file and manifest properties + */ + getManifestsForDirectory: function(dir) { + if (directoryManifests.has(dir)) { + return directoryManifests.get(dir); + } + + let manifests = []; + + let names = fs.readdirSync(dir); + for (let name of names) { + if (!name.endsWith(".ini")) { + continue; + } + + try { + let manifest = ini.parse(fs.readFileSync(path.join(dir, name), 'utf8')); + + manifests.push({ + file: path.join(dir, name), + manifest + }) + } catch (e) { + } + } + + directoryManifests.set(dir, manifests); + return manifests; + }, + + /** + * Gets the manifest file a test is listed in + * + * @param {RuleContext} scope + * You should pass this from within a rule + * e.g. helpers.getIsHeadFile(this) + * + * @return {String} + * The path to the test manifest file + */ + getTestManifest: function(scope) { + let filepath = this.cleanUpPath(scope.getFilename()); + + let dir = path.dirname(filepath); + let filename = path.basename(filepath); + + for (let manifest of this.getManifestsForDirectory(dir)) { + if (filename in manifest.manifest) { + return manifest.file; + } + } + + return null; + }, + + /** + * Check whether we are in a test of some kind. + * + * @param {RuleContext} scope + * You should pass this from within a rule + * e.g. helpers.getIsTest(this) + * + * @return {Boolean} + * True or false + */ + getIsTest: function(scope) { + // Regardless of the manifest name being in a manifest means we're a test. + let manifest = this.getTestManifest(scope); + if (manifest) { + return true; + } + + return !!this.getTestType(scope); + }, + + /** + * Gets the type of test or null if this isn't a test. + * + * @param {RuleContext} scope + * You should pass this from within a rule + * e.g. helpers.getIsHeadFile(this) + * + * @return {String or null} + * Test type: xpcshell, browser, chrome, mochitest + */ + getTestType: function(scope) { + let manifest = this.getTestManifest(scope); + if (manifest) { + let name = path.basename(manifest); + for (let testType of ["browser", "xpcshell", "chrome", "mochitest"]) { + if (name.startsWith(testType)) { + return testType; + } + } + } + + let filepath = this.cleanUpPath(scope.getFilename()); + let filename = path.basename(filepath); + + if (filename.startsWith("browser_")) { + return "browser"; + } + + if (filename.startsWith("test_")) { + return "xpcshell"; + } + + return null; + }, + + /** + * Gets the root directory of the repository by walking up directories until + * a .eslintignore file is found. + * @param {String} fileName + * The absolute path of a file in the repository + * + * @return {String} The absolute path of the repository directory + */ + getRootDir: function(fileName) { + var dirName = path.dirname(fileName); + + while (dirName && !fs.existsSync(path.join(dirName, ".eslintignore"))) { + dirName = path.dirname(dirName); + } + + if (!dirName) { + throw new Error("Unable to find root of repository"); + } + + return dirName; + }, + + /** + * ESLint may be executed from various places: from mach, at the root of the + * repository, or from a directory in the repository when, for instance, + * executed by a text editor's plugin. + * The value returned by context.getFileName() varies because of this. + * This helper function makes sure to return an absolute file path for the + * current context, by looking at process.cwd(). + * @param {Context} context + * @return {String} The absolute path + */ + getAbsoluteFilePath: function(context) { + var fileName = this.cleanUpPath(context.getFilename()); + var cwd = process.cwd(); + + if (path.isAbsolute(fileName)) { + // Case 2: executed from the repo's root with mach: + // fileName: /path/to/mozilla/repo/a/b/c/d.js + // cwd: /path/to/mozilla/repo + return fileName; + } else if (path.basename(fileName) == fileName) { + // Case 1b: executed from a nested directory, fileName is the base name + // without any path info (happens in Atom with linter-eslint) + return path.join(cwd, fileName); + } else { + // Case 1: executed form in a nested directory, e.g. from a text editor: + // fileName: a/b/c/d.js + // cwd: /path/to/mozilla/repo/a/b/c + var dirName = path.dirname(fileName); + return cwd.slice(0, cwd.length - dirName.length) + fileName; + } + }, + + /** + * When ESLint is run from SublimeText, paths retrieved from + * context.getFileName contain leading and trailing double-quote characters. + * These characters need to be removed. + */ + cleanUpPath: function(path) { + return path.replace(/^"/, "").replace(/"$/, ""); + } +}; |