fune/tools/lint/eslint/eslint-plugin-mozilla/lib/helpers.js
Mark Banner 4618d676aa Bug 1890330 - Support new ESLint APIs in eslint-plugin-mozilla. r=frontend-codestyle-reviewers,Gijs
This gets the ESLint plugin code in a state where it can work with the old APIs or the newer ESLint v9 ones.

The testing system will be updated to ESLint v9 when we do the main upgrade. However, in the meantime, this allows
consumers to start testing out v9 implementations.

Differential Revision: https://phabricator.services.mozilla.com/D206914
2024-04-09 08:11:09 +00:00

831 lines
23 KiB
JavaScript

/**
* @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");
const recommendedConfig = require("./configs/recommended");
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
* 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().
* @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.
// xref https://github.com/eslint/eslint-scope/issues/74
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 of the recommended config.
*
* @return {Number} The ECMA version of the recommended config.
*/
getECMAVersion() {
return recommendedConfig.parserOptions.ecmaVersion;
},
/**
* 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 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.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();
},
};