668 lines
20 KiB
JavaScript
668 lines
20 KiB
JavaScript
/**
|
|
* @fileoverview functions for scanning an AST for globals including
|
|
* traversing referenced scripts.
|
|
* 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 path = require("path");
|
|
const fs = require("fs");
|
|
const helpers = require("./helpers");
|
|
const htmlparser = require("htmlparser2");
|
|
const testharnessEnvironment = require("./environments/testharness.js");
|
|
|
|
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)/;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Global discovery can require parsing many files. This map of
|
|
* {String} => {Object} caches what globals were discovered for a file path.
|
|
*/
|
|
const globalCache = new Map();
|
|
|
|
/**
|
|
* Global discovery can occasionally meet circular dependencies due to the way
|
|
* js files are included via html/xhtml files etc. This set is used to avoid
|
|
* getting into loops whilst the discovery is in progress.
|
|
*/
|
|
var globalDiscoveryInProgressForFiles = new Set();
|
|
|
|
/**
|
|
* 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 convert an CallExpressions that look like module imports
|
|
* into 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.
|
|
*/
|
|
function 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,
|
|
};
|
|
});
|
|
}
|
|
|
|
// 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 subScriptMatches) {
|
|
let match = source.match(reg);
|
|
if (match) {
|
|
return getGlobalsForScript(match[1], "script").map(g => {
|
|
// We don't want any loadSubScript globals to be explicit, as this
|
|
// could trigger no-unused-vars when importing multiple variables
|
|
// from a script and not using all of them.
|
|
g.explicit = false;
|
|
return g;
|
|
});
|
|
}
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function 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 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.
|
|
*/
|
|
function convertWorkerExpressionToGlobals(node, isGlobal, dirname) {
|
|
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 = module.exports.getGlobalsForFile(filePath);
|
|
results = results.concat(additionalGlobals);
|
|
}
|
|
}
|
|
// Import with relative/absolute path should explicitly use
|
|
// `import-globals-from` comment.
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 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 {object[]}
|
|
* An array of objects with details of the globals in them.
|
|
*/
|
|
function getGlobalsForScript(src, type, 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")) {
|
|
return Object.keys(testharnessEnvironment.globals).map(name => ({
|
|
name,
|
|
writable: true,
|
|
}));
|
|
} else if (dir) {
|
|
// Fallback to hoping this is a relative path.
|
|
scriptName = path.join(dir, src);
|
|
}
|
|
if (scriptName && fs.existsSync(scriptName)) {
|
|
return module.exports.getGlobalsForFile(scriptName, {
|
|
ecmaVersion: helpers.getECMAVersion(),
|
|
sourceType: type,
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param {String} filePath
|
|
* The absolute path of the file being parsed.
|
|
*/
|
|
function GlobalsForNode(filePath, context) {
|
|
this.path = filePath;
|
|
this.context = context;
|
|
|
|
if (this.path) {
|
|
this.dirname = path.dirname(this.path);
|
|
} else {
|
|
this.dirname = null;
|
|
}
|
|
}
|
|
|
|
GlobalsForNode.prototype = {
|
|
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,
|
|
});
|
|
}
|
|
// We matched globals, so we won't match import-globals-from.
|
|
continue;
|
|
}
|
|
|
|
match = /^import-globals-from\s+(.+)$/.exec(value);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
|
|
if (!this.dirname) {
|
|
// If this is testing context without path, ignore import.
|
|
return globals;
|
|
}
|
|
|
|
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);
|
|
}
|
|
globals = globals.concat(module.exports.getGlobalsForFile(filePath));
|
|
}
|
|
|
|
return globals;
|
|
},
|
|
|
|
ExpressionStatement(node, parents, globalScope) {
|
|
let isGlobal = helpers.getIsGlobalThis(parents);
|
|
let globals = [];
|
|
|
|
// Note: We check the expression types here and only call the necessary
|
|
// functions to aid performance.
|
|
if (node.expression.type === "AssignmentExpression") {
|
|
globals = convertThisAssignmentExpressionToGlobals(node, isGlobal);
|
|
} else if (node.expression.type === "CallExpression") {
|
|
globals = convertCallExpressionToGlobals(node, isGlobal);
|
|
}
|
|
|
|
// Here we assume that if importScripts is set in the global scope, then
|
|
// this is a worker. It would be nice if eslint gave us a way of getting
|
|
// the environment directly.
|
|
//
|
|
// If this is testing context without path, ignore import.
|
|
if (globalScope && globalScope.set.get("importScripts") && this.dirname) {
|
|
let workerDetails = convertWorkerExpressionToGlobals(
|
|
node,
|
|
isGlobal,
|
|
this.dirname
|
|
);
|
|
globals = globals.concat(workerDetails);
|
|
}
|
|
|
|
return globals;
|
|
},
|
|
};
|
|
|
|
module.exports = {
|
|
/**
|
|
* Returns all globals for a given file. Recursively searches through
|
|
* import-globals-from directives and also includes globals defined by
|
|
* standard eslint directives.
|
|
*
|
|
* @param {String} filePath
|
|
* The absolute path of the file to be parsed.
|
|
* @param {Object} astOptions
|
|
* Extra options to pass to the parser.
|
|
* @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.
|
|
*/
|
|
getGlobalsForFile(filePath, astOptions = {}) {
|
|
if (globalCache.has(filePath)) {
|
|
return globalCache.get(filePath);
|
|
}
|
|
|
|
if (globalDiscoveryInProgressForFiles.has(filePath)) {
|
|
// We're already processing this file, so return an empty set for now -
|
|
// the initial processing will pick up on the globals for this file.
|
|
return [];
|
|
}
|
|
globalDiscoveryInProgressForFiles.add(filePath);
|
|
|
|
let content = fs.readFileSync(filePath, "utf8");
|
|
|
|
// Parse the content into an AST
|
|
let { ast, scopeManager, visitorKeys } = helpers.parseCode(
|
|
content,
|
|
astOptions
|
|
);
|
|
|
|
// Discover global declarations
|
|
let globalScope = scopeManager.acquire(ast);
|
|
|
|
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(filePath);
|
|
|
|
helpers.walkAST(ast, visitorKeys, (type, node, parents) => {
|
|
if (type in handler) {
|
|
let newGlobals = handler[type](node, parents, globalScope);
|
|
globals.push.apply(globals, newGlobals);
|
|
}
|
|
});
|
|
|
|
globalCache.set(filePath, globals);
|
|
|
|
globalDiscoveryInProgressForFiles.delete(filePath);
|
|
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.
|
|
* @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.
|
|
*/
|
|
getGlobalsForCode(code, astOptions = {}) {
|
|
// Parse the content into an AST
|
|
let { ast, scopeManager, visitorKeys } = helpers.parseCode(
|
|
code,
|
|
astOptions,
|
|
{ useBabel: false }
|
|
);
|
|
|
|
// Discover global declarations
|
|
let globalScope = scopeManager.acquire(ast);
|
|
|
|
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(null);
|
|
|
|
helpers.walkAST(ast, visitorKeys, (type, node, parents) => {
|
|
if (type in handler) {
|
|
let newGlobals = handler[type](node, parents, globalScope);
|
|
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.
|
|
* @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.
|
|
*/
|
|
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 globalScope;
|
|
|
|
let parser = {
|
|
Program(node) {
|
|
globalScope = context.sourceCode.getScope(node);
|
|
},
|
|
};
|
|
let filename = context.getFilename();
|
|
|
|
let extraHTMLGlobals = [];
|
|
if (filename.endsWith(".html") || filename.endsWith(".xhtml")) {
|
|
extraHTMLGlobals = module.exports.getImportedGlobalsForHTMLFile(filename);
|
|
}
|
|
|
|
// Install thin wrappers around GlobalsForNode
|
|
let handler = new GlobalsForNode(helpers.getAbsoluteFilePath(context));
|
|
|
|
for (let type of Object.keys(GlobalsForNode.prototype)) {
|
|
parser[type] = function (node) {
|
|
if (type === "Program") {
|
|
globalScope = context.sourceCode.getScope(node);
|
|
helpers.addGlobals(extraHTMLGlobals, globalScope);
|
|
}
|
|
let globals = handler[type](
|
|
node,
|
|
context.sourceCode.getAncestors(node),
|
|
globalScope
|
|
);
|
|
helpers.addGlobals(
|
|
globals,
|
|
globalScope,
|
|
node.type !== "Program" && node
|
|
);
|
|
};
|
|
}
|
|
|
|
return parser;
|
|
},
|
|
};
|