Source code

Revision control

Copy as Markdown

Other Tools

/**
* @file functions for scanning code for globals, and imported globals.
* When scanning files, `FileImportASTHandler` is used to build a tree of the
* imports. The tree is then used as an iteration point, and `GlobalsForNode`
* is used to build the list of globals for each individual file.
*
* 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/.
*/
import path from "path";
import fs from "fs";
import helpers from "./helpers.mjs";
import * as htmlparser from "htmlparser2";
import testharnessEnvironment from "./environments/testharness.mjs";
const callExpressionDefinitions = [
/^loader\.lazyGetter\((?:globalThis|this), "(\w+)"/,
/^loader\.lazyServiceGetter\((?:globalThis|this), "(\w+)"/,
/^loader\.lazyRequireGetter\((?:globalThis|this), "(\w+)"/,
/^ChromeUtils\.defineLazyGetter\((?:globalThis|this), "(\w+)"/,
/^XPCOMUtils\.defineLazyPreferenceGetter\((?:globalThis|this), "(\w+)"/,
/^XPCOMUtils\.defineLazyScriptGetter\((?:globalThis|this), "(\w+)"/,
/^XPCOMUtils\.defineLazyServiceGetter\((?:globalThis|this), "(\w+)"/,
/^XPCOMUtils\.defineConstant\((?:globalThis|this), "(\w+)"/,
/^DevToolsUtils\.defineLazyGetter\((?:globalThis|this), "(\w+)"/,
/^Object\.defineProperty\((?:globalThis|this), "(\w+)"/,
/^Reflect\.defineProperty\((?:globalThis|this), "(\w+)"/,
/^this\.__defineGetter__\("(\w+)"/,
];
const callExpressionMultiDefinitions = [
"XPCOMUtils.defineLazyGlobalGetters(this,",
"XPCOMUtils.defineLazyGlobalGetters(globalThis,",
"XPCOMUtils.defineLazyServiceGetters(this,",
"XPCOMUtils.defineLazyServiceGetters(globalThis,",
"ChromeUtils.defineESModuleGetters(this,",
"ChromeUtils.defineESModuleGetters(globalThis,",
"loader.lazyRequireGetter(this,",
"loader.lazyRequireGetter(globalThis,",
];
const subScriptMatches = [
/Services\.scriptloader\.loadSubScript\("(.*?)", this\)/,
];
const workerImportFilenameMatch = /(.*\/)*((.*?)\.js)/;
/**
* @typedef {{name: string, writable: boolean, explicit?: boolean}[]} GlobalsList
* A list of globals and if they are writeable or not.
*/
/**
* Parses a list of "name:boolean_value" or/and "name" options divided by comma
* or whitespace.
*
* This function was copied from eslint.js
*
* @param {string} string The string to parse.
* @param {Comment} comment The comment node which has the string.
* @returns {object} Result map object of names and boolean values
*/
function parseBooleanConfig(string, comment) {
let items = {};
// Collapse whitespace around : to make parsing easier
string = string.replace(/\s*:\s*/g, ":");
// Collapse whitespace around ,
string = string.replace(/\s*,\s*/g, ",");
string.split(/\s|,+/).forEach(function (name) {
if (!name) {
return;
}
let pos = name.indexOf(":");
let value;
if (pos !== -1) {
value = name.substring(pos + 1, name.length);
name = name.substring(0, pos);
}
items[name] = {
value: value === "true",
comment,
};
});
return items;
}
/**
* When looking for globals in HTML files, it can be common to have more than
* one script tag with inline javascript. These will normally be called together,
* so we store the globals for just the last HTML file processed.
*/
var lastHTMLGlobals = {};
/**
* Attempts to figure out the location for the given script.
*
* @param {string} src
* The source path or url of the script to look for.
* @param {string} [dir]
* The directory of the current file.
* @returns {string?}
*/
function getScriptLocation(src, dir) {
let scriptName;
if (src.includes("http:")) {
// We don't handle this currently as the paths are complex to match.
} else if (src.startsWith("chrome://mochikit/content/")) {
// Various ways referencing test files.
src = src.replace("chrome://mochikit/content/", "/");
scriptName = path.join(helpers.rootDir, "testing", "mochitest", src);
} else if (src.startsWith("chrome://mochitests/content/browser")) {
src = src.replace("chrome://mochitests/content/browser", "");
scriptName = path.join(helpers.rootDir, src);
} else if (src.includes("SimpleTest")) {
// This is another way of referencing test files...
scriptName = path.join(helpers.rootDir, "testing", "mochitest", src);
} else if (src.startsWith("/tests/")) {
scriptName = path.join(helpers.rootDir, src.substring(7));
} else if (src.startsWith("/resources/testharness.js")) {
scriptName = src;
} else if (dir) {
// Fallback to hoping this is a relative path.
scriptName = path.join(dir, src);
}
if (scriptName && fs.existsSync(scriptName)) {
return scriptName;
}
return null;
}
/**
* Attempts to load the globals for a given script.
*
* @param {string} src
* The source path or url of the script to look for.
* @param {string} type
* The type of the current file (script/module).
* @param {string} [dir]
* The directory of the current file.
* @returns {GlobalsList}
*/
function getGlobalsForScript(src, type, dir) {
if (src.startsWith("/resources/testharness.js")) {
return Object.keys(testharnessEnvironment.globals).map(name => ({
name,
writable: true,
}));
}
let scriptName = getScriptLocation(src, dir);
if (scriptName) {
return globalUtils.getGlobalsForFile({
filePath: scriptName,
astOptions: {
ecmaVersion: helpers.getECMAVersion(),
sourceType: type,
},
});
}
return [];
}
/**
* A class that wraps the standard Map class with some utility functions to
* simplify adding and extracting details.
*
* @augments {Map<string, Set<string>>}
*/
class GraphMap extends Map {
/**
* Adds a new element with a specified key and value to the Map. The value
* will be wrapped in a Set.
* If an element with the same key already exists, the set will be updated.
*
* @param {string} key
* @param {string} [value]
*/
addEntry(key, value) {
let entry = this.get(key);
if (!entry) {
super.set(key, new Set(value ? [value] : []));
} else {
entry.add(value);
}
}
/**
* Returns a Set of values that are collated from looking at the graph and
* sub-graphs from the given key. The initial key is also included in the
* result.
*
* @param {string} key
*/
getSubGraph(key) {
let result = new Set([key]);
/** @type { (innerFile: string) => void} */
let inner = innerFile => {
result.add(innerFile);
let initialGraph = this.get(innerFile);
if (!initialGraph) {
return;
}
for (let file of initialGraph) {
if (!result.has(file)) {
inner(file);
}
}
};
inner(key);
return result;
}
}
/**
* A map of file paths mapped to the files that they directly import via
* import-globals-from and other constructs.
*/
let fileImportGraph = new GraphMap();
/**
* A map of file paths mapped to the globals found in those files. These exclude
* globals imported via import-globals-from.
*
* @type {Map<string, GlobalsList>}
*/
let fileGlobalsMap = new Map();
/**
* A class which acts as a AST walker for a files, to extract information
* about the files which are imported into the file.
*/
class FileImportASTHandler {
/**
* The context associated with the current processing. This is used to report
* any found issues.
*/
#context;
/**
* The directory name of the file being processed.
*/
#dirname;
/**
* The absolute path of the file being processed.
*/
#path;
/**
* @param {string} filePath
* The absolute path of the file being parsed.
* @param {object} context
* The context associated with the node.
*/
constructor(filePath, context) {
this.#path = filePath;
this.#context = context;
if (this.#path) {
this.#dirname = path.dirname(this.#path);
} else {
this.#dirname = null;
}
}
Program(node) {
if (!this.#dirname) {
// If this is testing context without path, ignore import-global-from statements.
return;
}
// Look for any import-globals-from statements.
for (let comment of node.comments) {
if (comment.type !== "Block") {
continue;
}
let value = comment.value.trim();
let match = /^import-globals-from\s+(.+)$/.exec(value.replace(/\n/g, ""));
if (!match) {
continue;
}
let filePath = match[1].trim();
if (filePath.endsWith(".mjs")) {
if (this.#context) {
this.#context.report(
comment,
"import-globals-from does not support module files - use a direct import instead"
);
} else {
// Fall back to throwing an error, as we do not have a context in all situations,
// e.g. when loading the environment.
throw new Error(
"import-globals-from does not support module files - use a direct import instead"
);
}
continue;
}
if (!path.isAbsolute(filePath)) {
filePath = path.resolve(this.#dirname, filePath);
} else {
filePath = path.join(helpers.rootDir, filePath);
}
fileImportGraph.addEntry(this.#path, filePath);
}
}
CallExpression(node) {
let source;
try {
source = helpers.getASTSource(node);
} catch (e) {
return;
}
for (let reg of subScriptMatches) {
let match = source.match(reg);
if (match) {
let filePath = getScriptLocation(match[1]);
if (filePath) {
fileImportGraph.addEntry(this.#path, filePath);
}
}
}
// Although `importScripts` is only available in workers, we check for it
// anyway, as it should not be an expensive check, and avoids us having to
// check if this is a worker or not. Incorrect uses should be detected by
// the ESLint no-undef rule, as `importScripts` should not be available
// in the environment for non-workers.
if (
node.callee?.type === "Identifier" &&
node.callee.name === "importScripts"
) {
for (var arg of node.arguments) {
var match = arg.value && arg.value.match(workerImportFilenameMatch);
if (match) {
if (!match[1]) {
let filePath = path.resolve(this.#dirname, match[2]);
if (fs.existsSync(filePath)) {
fileImportGraph.addEntry(this.#path, filePath);
}
}
// Import with relative/absolute path should explicitly use
// `import-globals-from` comment.
}
}
}
}
}
/**
* An object that returns found globals for given AST node types. Each prototype
* property should be named for a node type and accepts a node parameter and a
* parents parameter which is a list of the parent nodes of the current node.
* Each returns an array of globals found.
*/
class GlobalsForNode {
Program(node) {
let globals = [];
for (let comment of node.comments) {
if (comment.type !== "Block") {
continue;
}
let value = comment.value.trim();
value = value.replace(/\n/g, "");
// We have to discover any globals that ESLint would have defined through
// comment directives.
let match = /^globals?\s+(.+)/.exec(value);
if (match) {
let values = parseBooleanConfig(match[1].trim(), node);
for (let name of Object.keys(values)) {
globals.push({
name,
writable: values[name].value,
});
}
}
}
return globals;
}
/**
* 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 {object} parents
* The details of any parents of the current node.
*
* @returns {GlobalsList}
*/
AssignmentExpression(node, parents) {
let isGlobal = helpers.getIsGlobalThis(parents);
if (
isGlobal &&
node.left?.object?.type === "ThisExpression" &&
node.left?.property?.type === "Identifier"
) {
return [{ name: node.left.property.name, writable: true }];
}
return [];
}
/**
* Attempts to convert a CallExpression that looks like a module import
* into global variable definitions.
*
* @param {object} node
* The AST node to convert.
* @param {object} parents
* The details of any parents of the current node.
* @returns {GlobalsList}
*/
CallExpression(node, parents) {
let isGlobal = helpers.getIsGlobalThis(parents);
if (
node.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
node.callee.object?.type === "Identifier" &&
node.arguments[0]?.type === "ArrayExpression" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "importGlobalProperties"
) {
return node.arguments[0].elements.map(literal => {
return {
explicit: true,
name: literal.value,
writable: false,
};
});
}
// 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 [];
}
let source;
try {
source = helpers.getASTSource(node);
} catch (e) {
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.arguments[1]
) {
let arg = node.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.callee.type == "MemberExpression" &&
node.callee.property.type == "Identifier" &&
node.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.arguments[1].elements.map(n => ({
name: n.value,
writable: true,
explicit: true,
}));
}
return [];
}
}
let globalUtils = {
/**
* Ensures that there is a complete file import tree for the given filePath.
*
* @param {object} options
* @param {string} options.filePath
* The path of the file to start from.
* @param {object} [options.context]
* The ESLint rule context for the current file being processed.
* @param {object} [options.astOptions]
* Any AST options that the parser needs.
* @returns {GraphMap}
* Returned to allow tests to check correct behaviour.
*/
ensureFileTree({ filePath, context = null, astOptions = {} }) {
if (fileImportGraph.has(filePath)) {
return fileImportGraph;
}
fileImportGraph.addEntry(filePath);
let content = fs.readFileSync(filePath, "utf8");
// Parse the content into an AST
let { ast, visitorKeys } = helpers.parseCode(content, astOptions);
let handler = new FileImportASTHandler(filePath, context);
// Now find all the imports.
helpers.walkAST(ast, visitorKeys, (type, node) => {
if (type in handler) {
handler[type](node);
}
});
// Now ensure we have import details for all the imports.
for (let file of fileImportGraph.get(filePath)) {
this.ensureFileTree({ filePath: file, context, astOptions });
}
return fileImportGraph;
},
/**
* Returns all globals for a given file. Recursively searches through
* import-globals-from directives and also includes globals defined by
* standard eslint directives.
*
* @param {object} options
* @param {string} [options.filePath]
* The absolute path of the file to be parsed.
* @param {object} [options.context]
* The ESLint rule context for the current file being processed.
* @param {object} [options.astOptions]
* Any AST options that the parser needs.
*/
getGlobalsForFile({ filePath, context = null, astOptions = {} }) {
// First identify the full import graph.
this.ensureFileTree({ filePath, context, astOptions });
/** @type {GlobalsList} */
let globals = [];
// Now look for the globals in all of the files imported by this one.
for (let file of fileImportGraph.getSubGraph(filePath).values()) {
if (fileGlobalsMap.has(file)) {
globals = [...globals, ...fileGlobalsMap.get(file)];
continue;
}
let content = fs.readFileSync(file, "utf8");
// Parse the content into an AST
let { ast, scopeManager, visitorKeys } = helpers.parseCode(
content,
astOptions
);
// Discover global declarations
let globalScope = scopeManager.acquire(ast);
/** @type {GlobalsList} */
let globalsInFile = Object.keys(globalScope.variables).map(v => ({
name: globalScope.variables[v].name,
writable: true,
}));
// Walk over the AST to find any of our custom globals
let handler = new GlobalsForNode();
helpers.walkAST(ast, visitorKeys, (type, node, parents) => {
if (type in handler) {
let newGlobals = handler[type](node, parents);
globalsInFile.push(...newGlobals);
}
});
fileGlobalsMap.set(file, globalsInFile);
globals = [...globals, ...fileGlobalsMap.get(file)];
}
return globals;
},
/**
* Returns all globals for a code.
* This is only for testing.
*
* @param {string} code
* The JS code
* @param {object} astOptions
* Extra options to pass to the parser.
*/
getGlobalsForCode(code, astOptions = {}) {
// Parse the content into an AST
let { ast, scopeManager, visitorKeys } = helpers.parseCode(
code,
astOptions
);
// Discover global declarations
let globalScope = scopeManager.acquire(ast);
/** @type {GlobalsList} */
let globals = Object.keys(globalScope.variables).map(v => ({
name: globalScope.variables[v].name,
writable: true,
}));
// Walk over the AST to find any of our custom globals
let handler = new GlobalsForNode();
helpers.walkAST(ast, visitorKeys, (type, node, parents) => {
if (type in handler) {
let newGlobals = handler[type](node, parents);
globals.push.apply(globals, newGlobals);
}
});
return globals;
},
/**
* Returns all the globals for an html file that are defined by imported
* scripts (i.e. <script src="foo.js">).
*
* This function will cache results for one html file only - we expect
* this to be called sequentially for each chunk of a HTML file, rather
* than chucks of different files in random order.
*
* @param {string} filePath
* The absolute path of the file to be parsed.
* @returns {GlobalsList}
*/
getImportedGlobalsForHTMLFile(filePath) {
if (lastHTMLGlobals.filename === filePath) {
return lastHTMLGlobals.globals;
}
let dir = path.dirname(filePath);
let globals = [];
let content = fs.readFileSync(filePath, "utf8");
let scriptSrcs = [];
// We use htmlparser as this ensures we find the script tags correctly.
let parser = new htmlparser.Parser(
{
onopentag(name, attribs) {
if (name === "script" && "src" in attribs) {
scriptSrcs.push({
src: attribs.src,
type:
"type" in attribs && attribs.type == "module"
? "module"
: "script",
});
}
},
},
{
xmlMode: filePath.endsWith("xhtml"),
}
);
parser.parseComplete(content);
for (let script of scriptSrcs) {
// Ensure that the script src isn't just "".
if (!script.src) {
continue;
}
globals.push(...getGlobalsForScript(script.src, script.type, dir));
}
lastHTMLGlobals.filePath = filePath;
return (lastHTMLGlobals.globals = globals);
},
/**
* Intended to be used as-is for an ESLint rule that parses for globals in
* the current file and recurses through import-globals-from directives.
*
* @param {object} context
* The ESLint parsing context.
*/
getESLintGlobalParser(context) {
let filename = context.getFilename();
return {
Program: node => {
let globalScope = context.sourceCode.getScope(node);
if (filename.endsWith(".html") || filename.endsWith(".xhtml")) {
let filePath = helpers.getAbsoluteFilePath(context);
let globals = globalUtils.getImportedGlobalsForHTMLFile(filename);
helpers.addGlobals(globals, globalScope);
let fileImportHandler = new FileImportASTHandler(filePath, context);
fileImportHandler.Program(node);
for (let file of fileImportGraph.getSubGraph(filePath)) {
if (file == filePath) {
continue;
}
globals = globalUtils.getGlobalsForFile({
filePath: file,
context,
astOptions: {
...context.languageOptions?.parserOptions,
},
});
helpers.addGlobals(globals, globalScope);
}
} else {
let globals = globalUtils.getGlobalsForFile({
filePath: filename,
context,
astOptions: {
...context.languageOptions?.parserOptions,
},
});
helpers.addGlobals(globals, globalScope);
}
},
};
},
/**
* Used for tests to clear the import graph in-between tests.
*/
clearFileImportGraph() {
fileImportGraph.clear();
},
};
export default globalUtils;