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 | 881 |
1 files changed, 881 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 0000000000..a3a5fcf8e7 --- /dev/null +++ b/tools/lint/eslint/eslint-plugin-mozilla/lib/helpers.js @@ -0,0 +1,881 @@ +/** + * @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"; + +const parser = require("babel-eslint"); +const { analyze } = require("eslint-scope"); +const { KEYS: defaultVisitorKeys } = require("eslint-visitor-keys"); +const estraverse = require("estraverse"); +const path = require("path"); +const fs = require("fs"); +const ini = require("multi-ini"); +const recommendedConfig = require("./configs/recommended"); + +var gModules = null; +var gRootDir = null; +var directoryManifests = new Map(); + +const callExpressionDefinitions = [ + /^loader\.lazyGetter\(this, "(\w+)"/, + /^loader\.lazyImporter\(this, "(\w+)"/, + /^loader\.lazyServiceGetter\(this, "(\w+)"/, + /^loader\.lazyRequireGetter\(this, "(\w+)"/, + /^XPCOMUtils\.defineLazyGetter\(this, "(\w+)"/, + /^XPCOMUtils\.defineLazyModuleGetter\(this, "(\w+)"/, + /^ChromeUtils\.defineModuleGetter\(this, "(\w+)"/, + /^XPCOMUtils\.defineLazyPreferenceGetter\(this, "(\w+)"/, + /^XPCOMUtils\.defineLazyProxy\(this, "(\w+)"/, + /^XPCOMUtils\.defineLazyScriptGetter\(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+)"/, +]; + +const callExpressionMultiDefinitions = [ + "XPCOMUtils.defineLazyGlobalGetters(this,", + "XPCOMUtils.defineLazyModuleGetters(this,", + "XPCOMUtils.defineLazyServiceGetters(this,", + "loader.lazyRequireGetter(this,", +]; + +const imports = [ + /^(?:Cu|Components\.utils|ChromeUtils)\.import\(".*\/((.*?)\.jsm?)", this\)/, +]; + +const workerImportFilenameMatch = /(.*\/)*((.*?)\.jsm?)/; + +module.exports = { + get iniParser() { + if (!this._iniParser) { + this._iniParser = new ini.Parser(); + } + return this._iniParser; + }, + + get modulesGlobalData() { + if (!gModules) { + if (this.isMozillaCentralBased()) { + gModules = require(path.join( + this.rootDir, + "tools", + "lint", + "eslint", + "modules.json" + )); + } else { + gModules = require("./modules.json"); + } + } + + return gModules; + }, + + get servicesData() { + return require("./services.json"); + }, + + /** + * Gets the abstract syntax tree (AST) of the JavaScript source code contained + * in sourceText. This matches the results for an eslint parser, see + * https://eslint.org/docs/developer-guide/working-with-custom-parsers. + * + * @param {String} sourceText + * Text containing valid JavaScript. + * @param {Object} astOptions + * Extra configuration to pass to the espree parser, these will override + * the configuration from getPermissiveConfig(). + * + * @return {Object} + * Returns an object containing `ast`, `scopeManager` and + * `visitorKeys` + */ + parseCode(sourceText, astOptions = {}) { + // Use a permissive config file to allow parsing of anything that Espree + // can parse. + let config = { ...this.getPermissiveConfig(), ...astOptions }; + + let parseResult = + "parseForESLint" in parser + ? parser.parseForESLint(sourceText, config) + : { ast: parser.parse(sourceText, config) }; + + let visitorKeys = parseResult.visitorKeys || defaultVisitorKeys; + visitorKeys.ExperimentalRestProperty = visitorKeys.RestElement; + visitorKeys.ExperimentalSpreadProperty = visitorKeys.SpreadElement; + + return { + ast: parseResult.ast, + scopeManager: parseResult.scopeManager || analyze(parseResult.ast), + visitorKeys, + }; + }, + + /** + * 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(node, context) { + switch (node.type) { + case "MemberExpression": + if (node.computed) { + let filename = context && context.getFilename(); + throw new Error( + `getASTSource unsupported computed MemberExpression in ${filename}` + ); + } + 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 "ArrayExpression": + return "[" + node.elements.map(this.getASTSource, this).join(",") + "]"; + case "ArrowFunctionExpression": + return "() => {}"; + case "AssignmentExpression": + return ( + this.getASTSource(node.left) + " = " + this.getASTSource(node.right) + ); + case "BinaryExpression": + return ( + this.getASTSource(node.left) + + " " + + node.operator + + " " + + 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 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 {Array} visitorKeys + * The visitor keys to use for the AST. + * @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, visitorKeys, listener) { + let parents = []; + + estraverse.traverse(ast, { + enter(node, parent) { + listener(node.type, node, parents); + + parents.push(node); + }, + + leave(node, parent) { + if (parents.length == 0) { + throw new Error("Left more nodes than entered."); + } + parents.pop(); + }, + + keys: visitorKeys, + }); + 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. + * + * @return {Array} + * An array of objects that contain details about the globals: + * - {String} name + * The name of the global. + * - {Boolean} writable + * If the global is writeable or not. + */ + convertWorkerExpressionToGlobals(node, isGlobal, dirname) { + var getGlobalsForFile = require("./globals").getGlobalsForFile; + + let globalModules = this.modulesGlobalData; + + let results = []; + let expr = node.expression; + + if ( + node.expression.type === "CallExpression" && + expr.callee && + expr.callee.type === "Identifier" && + expr.callee.name === "importScripts" + ) { + for (var arg of expr.arguments) { + var match = arg.value && arg.value.match(workerImportFilenameMatch); + if (match) { + if (!match[1]) { + let filePath = path.resolve(dirname, match[2]); + if (fs.existsSync(filePath)) { + let additionalGlobals = getGlobalsForFile(filePath); + results = results.concat(additionalGlobals); + } + } else if (match[2] in globalModules) { + results = results.concat( + globalModules[match[2]].map(name => { + return { name, writable: true }; + }) + ); + } else { + results.push({ name: match[3], writable: true, explicit: true }); + } + } + } + } + + return results; + }, + + /** + * Attempts to convert an AssignmentExpression into a global variable + * definition if it applies to `this` in the global scope. + * + * @param {Object} node + * The AST node to convert. + * @param {boolean} isGlobal + * True if the current node is in the global scope. + * + * @return {Array} + * An array of objects that contain details about the globals: + * - {String} name + * The name of the global. + * - {Boolean} writable + * If the global is writeable or not. + */ + convertThisAssignmentExpressionToGlobals(node, isGlobal) { + if ( + isGlobal && + node.expression.left && + node.expression.left.object && + node.expression.left.object.type === "ThisExpression" && + node.expression.left.property && + node.expression.left.property.type === "Identifier" + ) { + return [{ name: node.expression.left.property.name, writable: true }]; + } + return []; + }, + + /** + * Attempts to convert an CallExpressions that look like module imports + * into global variable definitions, using modules.json data if appropriate. + * + * @param {Object} node + * The AST node to convert. + * @param {boolean} isGlobal + * True if the current node is in the global scope. + * + * @return {Array} + * An array of objects that contain details about the globals: + * - {String} name + * The name of the global. + * - {Boolean} writable + * If the global is writeable or not. + */ + convertCallExpressionToGlobals(node, isGlobal) { + let express = node.expression; + if ( + express.type === "CallExpression" && + express.callee.type === "MemberExpression" && + express.callee.object && + express.callee.object.type === "Identifier" && + express.arguments.length === 1 && + express.arguments[0].type === "ArrayExpression" && + express.callee.property.type === "Identifier" && + express.callee.property.name === "importGlobalProperties" + ) { + return express.arguments[0].elements.map(literal => { + return { + explicit: true, + name: literal.value, + writable: false, + }; + }); + } + + let source; + try { + source = this.getASTSource(node); + } catch (e) { + return []; + } + + for (let reg of imports) { + let 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 []; + } + + let globalModules = this.modulesGlobalData; + + if (match[1] in globalModules) { + // XXX We mark as explicit when there is only one exported symbol from + // the module. For now this avoids no-unused-vars complaining in the + // cases where we import everything from a module but only use one + // of them. + let explicit = globalModules[match[1]].length == 1; + return globalModules[match[1]].map(name => ({ + name, + writable: true, + explicit, + })); + } + + return [{ name: match[2], writable: true, explicit: true }]; + } + } + + // The definition matches below must be in the global scope for us to define + // a global, so bail out early if we're not a global. + if (!isGlobal) { + return []; + } + + for (let reg of callExpressionDefinitions) { + let match = source.match(reg); + if (match) { + return [{ name: match[1], writable: true, explicit: true }]; + } + } + + if ( + callExpressionMultiDefinitions.some(expr => source.startsWith(expr)) && + node.expression.arguments[1] + ) { + let arg = node.expression.arguments[1]; + if (arg.type === "ObjectExpression") { + return arg.properties + .map(p => ({ + name: p.type === "Property" && p.key.name, + writable: true, + explicit: true, + })) + .filter(g => g.name); + } + if (arg.type === "ArrayExpression") { + return arg.elements + .map(p => ({ + name: p.type === "Literal" && p.value, + writable: true, + explicit: true, + })) + .filter(g => typeof g.name == "string"); + } + } + + if ( + node.expression.callee.type == "MemberExpression" && + node.expression.callee.property.type == "Identifier" && + node.expression.callee.property.name == "defineLazyScriptGetter" + ) { + // The case where we have a single symbol as a string has already been + // handled by the regexp, so we have an array of symbols here. + return node.expression.arguments[1].elements.map(n => ({ + name: n.value, + writable: true, + explicit: true, + })); + } + + 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. + * @param {Object} [node] + * The AST node that defined the globals. + */ + addVarToScope(name, scope, writable, node) { + scope.__defineGeneric(name, scope.set, scope.variables, null, null); + + let variable = scope.set.get(name); + variable.eslintExplicitGlobal = false; + variable.writeable = writable; + if (node) { + variable.defs.push({ node, name: { name } }); + variable.identifiers.push(node); + } + + // 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. + * @param {Object} [node] + * The AST node that defined the globals. + */ + addGlobals(globalVars, scope, node) { + globalVars.forEach(v => + this.addVarToScope(v.name, scope, v.writable, v.explicit && node) + ); + }, + + /** + * 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() { + return { + range: true, + loc: true, + comment: true, + attachComment: true, + ecmaVersion: this.getECMAVersion(), + sourceType: "script", + }; + }, + + /** + * Returns the ECMA version of the recommended config. + * + * @return {Number} The ECMA version of the recommended config. + */ + getECMAVersion() { + return recommendedConfig.parserOptions.ecmaVersion; + }, + + /** + * Check whether a node is a function. + * + * @param {Object} node + * The AST node to check + * + * @return {Boolean} + * True or false + */ + getIsFunctionNode(node) { + switch (node.type) { + case "ArrowFunctionExpression": + case "FunctionDeclaration": + case "FunctionExpression": + return true; + } + return false; + }, + + /** + * Check whether the context is the global scope. + * + * @param {Array} ancestors + * The parents of the current node. + * + * @return {Boolean} + * True or false + */ + getIsGlobalScope(ancestors) { + for (let parent of ancestors) { + if (this.getIsFunctionNode(parent)) { + 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(context) + * + * @return {Boolean} + * True or false + */ + getIsHeadFile(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(context) + * + * @return {String[]} + * Paths to head files to load for the test + */ + getTestHeadFiles(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.startsWith("xpcshell-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(dir) { + if (directoryManifests.has(dir)) { + return directoryManifests.get(dir); + } + + let manifests = []; + let names = []; + try { + names = fs.readdirSync(dir); + } catch (err) { + // Ignore directory not found, it might be faked by a test + if (err.code !== "ENOENT") { + throw err; + } + } + + for (let name of names) { + if (!name.endsWith(".ini")) { + continue; + } + + try { + let manifest = this.iniParser.parse( + fs.readFileSync(path.join(dir, name), "utf8").split("\n") + ); + 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(context) + * + * @return {String} + * The path to the test manifest file + */ + getTestManifest(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(context) + * + * @return {Boolean} + * True or false + */ + getIsTest(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(context) + * + * @return {String or null} + * Test type: xpcshell, browser, chrome, mochitest + */ + getTestType(scope) { + let testTypes = ["browser", "xpcshell", "chrome", "mochitest", "a11y"]; + let manifest = this.getTestManifest(scope); + if (manifest) { + let name = path.basename(manifest); + for (let testType of testTypes) { + 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_")) { + let parent = path.basename(path.dirname(filepath)); + for (let testType of testTypes) { + if (parent.startsWith(testType)) { + return testType; + } + } + + // It likely is a test, we're just not sure what kind. + return "unknown"; + } + + // Likely not a test + return null; + }, + + getIsWorker(filePath) { + let filename = path.basename(this.cleanUpPath(filePath)).toLowerCase(); + + return filename.includes("worker"); + }, + + /** + * Gets the root directory of the repository by walking up directories from + * this file until a .eslintignore file is found. If this fails, the same + * procedure will be attempted from the current working dir. + * @return {String} The absolute path of the repository directory + */ + get rootDir() { + if (!gRootDir) { + function searchUpForIgnore(dirName, filename) { + let parsed = path.parse(dirName); + while (parsed.root !== dirName) { + if (fs.existsSync(path.join(dirName, filename))) { + return dirName; + } + // Move up a level + dirName = parsed.dir; + parsed = path.parse(dirName); + } + return null; + } + + let possibleRoot = searchUpForIgnore( + path.dirname(module.filename), + ".eslintignore" + ); + if (!possibleRoot) { + possibleRoot = searchUpForIgnore(path.resolve(), ".eslintignore"); + } + if (!possibleRoot) { + possibleRoot = searchUpForIgnore(path.resolve(), "package.json"); + } + if (!possibleRoot) { + // We've couldn't find a root from the module or CWD, so lets just go + // for the CWD. We really don't want to throw if possible, as that + // tends to give confusing results when used with ESLint. + possibleRoot = process.cwd(); + } + + gRootDir = possibleRoot; + } + + return gRootDir; + }, + + /** + * 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(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); + } + // 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(pathName) { + return pathName.replace(/^"/, "").replace(/"$/, ""); + }, + + get globalScriptPaths() { + return [ + path.join(this.rootDir, "browser", "base", "content", "browser.xhtml"), + path.join( + this.rootDir, + "browser", + "base", + "content", + "global-scripts.inc" + ), + ]; + }, + + isMozillaCentralBased() { + return fs.existsSync(this.globalScriptPaths[0]); + }, + + getSavedEnvironmentItems(environment) { + return require("./environments/saved-globals.json").environments[ + environment + ]; + }, + + getSavedRuleData(rule) { + return require("./rules/saved-rules-data.json").rulesData[rule]; + }, + + getBuildEnvironment() { + var { execFileSync } = require("child_process"); + var output = execFileSync( + path.join(this.rootDir, "mach"), + ["environment", "--format=json"], + { silent: true } + ); + return JSON.parse(output); + }, + + /** + * Extract the path of require (and require-like) helpers used in DevTools. + */ + getDevToolsRequirePath(node) { + if ( + node.callee.type == "Identifier" && + node.callee.name == "require" && + node.arguments.length == 1 && + node.arguments[0].type == "Literal" + ) { + return node.arguments[0].value; + } else if ( + node.callee.type == "MemberExpression" && + node.callee.property.type == "Identifier" && + (node.callee.property.name == "lazyRequireGetter" || + node.callee.property.name == "lazyImporter") && + node.arguments.length >= 3 && + node.arguments[2].type == "Literal" + ) { + return node.arguments[2].value; + } + return null; + }, +}; |