Source code

Revision control

Copy as Markdown

Other Tools

/**
* @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("espree");
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 toml = require("toml-eslint-parser");
var gRootDir = null;
var directoryManifests = new Map();
let xpidlData;
module.exports = {
get servicesData() {
return require("./services.json");
},
/**
* Obtains xpidl data from the object directory specified in the
* environment.
*
* @returns {Map<string, object>}
* A map of interface names to the interface details.
*/
get xpidlData() {
let xpidlDir;
if (process.env.TASK_ID && !process.env.MOZ_XPT_ARTIFACTS_DIR) {
throw new Error(
"MOZ_XPT_ARTIFACTS_DIR must be set for this rule in automation"
);
}
xpidlDir = process.env.MOZ_XPT_ARTIFACTS_DIR;
if (!xpidlDir && process.env.MOZ_OBJDIR) {
xpidlDir = `${process.env.MOZ_OBJDIR}/dist/xpt_artifacts/`;
if (!fs.existsSync(xpidlDir)) {
xpidlDir = `${process.env.MOZ_OBJDIR}/config/makefiles/xpidl/`;
}
}
if (!xpidlDir) {
throw new Error(
"MOZ_OBJDIR must be defined in the environment for this rule, i.e. MOZ_OBJDIR=objdir-ff ./mach ..."
);
}
if (xpidlData) {
return xpidlData;
}
let files = fs.readdirSync(`${xpidlDir}`);
// `Makefile` is an expected file in the directory.
if (files.length <= 1) {
throw new Error("Missing xpidl data files, maybe you need to build?");
}
xpidlData = new Map();
for (let file of files) {
if (!file.endsWith(".xpt")) {
continue;
}
let data = JSON.parse(fs.readFileSync(path.join(`${xpidlDir}`, file)));
for (let details of data) {
xpidlData.set(details.name, details);
}
}
return xpidlData;
},
/**
* Gets the abstract syntax tree (AST) of the JavaScript source code contained
* in sourceText. This matches the results for an eslint parser, see
*
* @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().
* @param {Object} configOptions
* Extra options for getPermissiveConfig().
*
* @return {Object}
* Returns an object containing `ast`, `scopeManager` and
* `visitorKeys`
*/
parseCode(sourceText, astOptions = {}, configOptions = {}) {
// Use a permissive config file to allow parsing of anything that Espree
// can parse.
let config = { ...this.getPermissiveConfig(configOptions), ...astOptions };
let parseResult = parser.parse(sourceText, config);
let visitorKeys = parseResult.visitorKeys || defaultVisitorKeys;
// eslint-scope doesn't support "latest" as a version, so we pass a really
// big number to ensure this always reads as the latest.
config.ecmaVersion =
config.ecmaVersion == "latest" ? 1e8 : config.ecmaVersion;
return {
ast: parseResult,
scopeManager: parseResult.scopeManager || analyze(parseResult, config),
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)
);
case "UnaryExpression":
return node.operator + " " + this.getASTSource(node.argument);
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) {
listener(node.type, node, parents);
parents.push(node);
},
leave() {
if (!parents.length) {
throw new Error("Left more nodes than entered.");
}
parents.pop();
},
keys: visitorKeys,
});
if (parents.length) {
throw new Error("Entered more nodes than left.");
}
},
/**
* 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({
type: "Variable",
node,
name: { name, parent: node.parent },
});
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.
*
* @param {Object} options
* {
* useBabel: {boolean} whether to set babelOptions.
* }
* @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 as the latest. It is generally assumed that we will
* always use the latest version in the configuration.
*
* @return {string} The ECMA version to use.
*/
getECMAVersion() {
return "latest";
},
/**
* Check whether it's inside top-level script.
*
* @param {Array} ancestors
* The parents of the current node.
*
* @return {Boolean}
* True or false
*/
getIsTopLevelScript(ancestors) {
for (let parent of ancestors) {
switch (parent.type) {
case "ArrowFunctionExpression":
case "FunctionDeclaration":
case "FunctionExpression":
case "PropertyDefinition":
case "StaticBlock":
return false;
}
}
return true;
},
isTopLevel(ancestors) {
for (let parent of ancestors) {
switch (parent.type) {
case "ArrowFunctionExpression":
case "FunctionDeclaration":
case "FunctionExpression":
case "PropertyDefinition":
case "StaticBlock":
case "BlockStatement":
return false;
}
}
return true;
},
/**
* Check whether `this` expression points the global this.
*
* @param {Array} ancestors
* The parents of the current node.
*
* @return {Boolean}
* True or false
*/
getIsGlobalThis(ancestors) {
for (let parent of ancestors) {
switch (parent.type) {
case "FunctionDeclaration":
case "FunctionExpression":
case "PropertyDefinition":
case "StaticBlock":
return false;
}
}
return true;
},
/**
* Check whether the node is evaluated at top-level script unconditionally.
*
* @param {Array} ancestors
* The parents of the current node.
*
* @return {Boolean}
* True or false
*/
getIsTopLevelAndUnconditionallyExecuted(ancestors) {
for (let parent of ancestors) {
switch (parent.type) {
// Control flow
case "IfStatement":
case "SwitchStatement":
case "TryStatement":
case "WhileStatement":
case "DoWhileStatement":
case "ForStatement":
case "ForInStatement":
case "ForOfStatement":
return false;
// Function
case "FunctionDeclaration":
case "FunctionExpression":
case "ArrowFunctionExpression":
case "ClassBody":
return false;
// Branch
case "LogicalExpression":
case "ConditionalExpression":
case "ChainExpression":
return false;
case "AssignmentExpression":
switch (parent.operator) {
// Branch
case "||=":
case "&&=":
case "??=":
return false;
}
break;
// Implicit branch (default value)
case "ObjectPattern":
case "ArrayPattern":
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(".toml")) {
try {
const ast = toml.parseTOML(
fs.readFileSync(path.join(dir, name), "utf8")
);
var manifest = {};
ast.body.forEach(top => {
if (top.type == "TOMLTopLevelTable") {
top.body.forEach(obj => {
if (obj.type == "TOMLTable") {
manifest[obj.resolvedKey] = {};
}
});
}
});
manifests.push({
file: path.join(dir, name),
manifest,
});
} catch (e) {
console.error(
"TOML ERROR: " +
e.message +
" @line: " +
e.lineNumber +
", column: " +
e.column
);
}
}
}
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);
},
/*
* Check if this is an .sjs file.
*/
getIsSjs(scope) {
let filepath = this.cleanUpPath(scope.getFilename());
return path.extname(filepath) == ".sjs";
},
/**
* 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 the top-level mozilla-central package.json 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 searchUpForPackage(dirName) {
let parsed = path.parse(dirName);
while (parsed.root !== dirName) {
let possibleFile = path.join(dirName, "package.json");
if (fs.existsSync(possibleFile)) {
let packageData = require(possibleFile);
if (packageData.name == "mozilla-central") {
return dirName;
}
}
// Move up a level
dirName = parsed.dir;
parsed = path.parse(dirName);
}
return null;
}
let possibleRoot = searchUpForPackage(path.dirname(module.filename));
if (!possibleRoot) {
possibleRoot = searchUpForPackage(path.resolve());
}
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.arguments.length >= 3 &&
node.arguments[2].type == "Literal"
) {
return node.arguments[2].value;
}
return null;
},
/**
* Returns property name from MemberExpression. Also accepts Identifier for consistency.
* @param {import("estree").MemberExpression | import("estree").Identifier} node
* @returns {string | null}
*
* @example `foo` gives "foo"
* @example `foo.bar` gives "bar"
* @example `foo.bar.baz` gives "baz"
*/
maybeGetMemberPropertyName(node) {
if (node.type === "MemberExpression") {
return node.property.name;
}
if (node.type === "Identifier") {
return node.name;
}
return null;
},
/**
* Gets the scope for a node taking account of where the scope function
* is available (supports node versions earlier than 8.37.0).
*
* @param {object} context
* The context passed from ESLint.
* @param {object} node
* The node to get the scope for.
* returns {function}
* The getScope function object.
*/
getScope(context, node) {
return context.sourceCode?.getScope
? context.sourceCode.getScope(node)
: context.getScope();
},
/**
* Gets the ancestors for a node taking account of where the ancestors function
* is available (supports node versions earlier than 8.38.0).
*
* @param {object} context
* The context passed from ESLint.
* @param {object} node
* The node to get the scope for.
* returns {function}
* The getScope function object.
*/
getAncestors(context, node) {
return context.sourceCode?.getAncestors
? context.sourceCode.getAncestors(node)
: context.getAncestors();
},
};