eslint-plugin-svelte
Version:
ESLint plugin for Svelte using AST
398 lines (397 loc) • 17.1 kB
JavaScript
import { ReferenceTracker, getStaticValue } from '@eslint-community/eslint-utils';
import { createRule } from '../utils/index.js';
import globals from 'globals';
import { findVariable, getScope } from '../utils/ast-utils.js';
export default createRule('no-top-level-browser-globals', {
meta: {
docs: {
description: 'disallow using top-level browser global variables',
category: 'Possible Errors',
recommended: false
},
schema: [],
messages: {
unexpectedGlobal: 'Unexpected top-level browser global variable "{{name}}".'
},
type: 'problem',
conditions: [{ svelteFileTypes: ['.svelte', '.svelte.[js|ts]'] }]
},
create(context) {
const sourceCode = context.sourceCode;
const blowerGlobals = getBrowserGlobals();
const referenceTracker = new ReferenceTracker(sourceCode.scopeManager.globalScope, {
// Specifies the global variables that are allowed to prevent `window.window` from being iterated over.
globalObjectNames: ['globalThis']
});
const maybeGuards = [];
const functions = [];
const typeAnnotations = [];
function enterFunction(node) {
if (isTopLevelLocation(node)) {
functions.push(node);
}
}
function enterTypeAnnotation(node) {
if (!isInTypeAnnotation(node)) {
typeAnnotations.push(node);
}
}
function enterMetaProperty(node) {
if (node.meta.name !== 'import' || node.property.name !== 'meta')
return;
for (const ref of referenceTracker.iteratePropertyReferences(node, {
env: {
// See https://vite.dev/guide/ssr#conditional-logic
SSR: {
[ReferenceTracker.READ]: true
}
}
})) {
if (ref.node.type === 'Identifier' || ref.node.type === 'MemberExpression') {
const guardChecker = getGuardChecker({ node: ref.node, not: true });
if (guardChecker) {
maybeGuards.push({
isAvailableLocation: guardChecker,
browserEnvironment: true
});
}
}
}
}
function verifyGlobalReferences() {
// Collects guarded location checkers by checking module references
// that can check the browser environment.
for (const referenceNode of iterateBrowserCheckerModuleReferences()) {
if (!isTopLevelLocation(referenceNode))
continue;
const guardChecker = getGuardChecker({ node: referenceNode });
if (guardChecker) {
maybeGuards.push({
isAvailableLocation: guardChecker,
browserEnvironment: true
});
}
}
const reportCandidates = [];
// Collects references to global variables.
for (const ref of iterateBrowserGlobalReferences()) {
if (!isTopLevelLocation(ref.node) || isInTypeAnnotation(ref.node))
continue;
const guardChecker = getGuardCheckerFromReference(ref.node);
if (guardChecker) {
const name = ref.path.join('.');
maybeGuards.push({
reference: { node: ref.node, name },
isAvailableLocation: guardChecker,
browserEnvironment: name === 'window' || name === 'document'
});
}
else {
reportCandidates.push(ref);
}
}
for (const ref of reportCandidates) {
const name = ref.path.join('.');
if (isAvailableLocation({ node: ref.node, name })) {
continue;
}
context.report({
node: ref.node,
messageId: 'unexpectedGlobal',
data: { name }
});
}
}
return {
':function': enterFunction,
SvelteSnippetBlock: enterFunction,
'*.typeAnnotation': enterTypeAnnotation,
MetaProperty: enterMetaProperty,
'Program:exit': verifyGlobalReferences
};
/**
* Checks whether the node is in a location where the expression is available or not.
* @returns `true` if the expression is available.
*/
function isAvailableLocation(ref) {
for (const guard of maybeGuards.reverse()) {
if (guard.isAvailableLocation(ref.node)) {
if (guard.browserEnvironment || guard.reference?.name === ref.name) {
return true;
}
}
}
return false;
}
/**
* Checks whether the node is in a top-level location.
* @returns `true` if the node is in a top-level location.
*/
function isTopLevelLocation(node) {
for (const func of functions) {
if (func.range[0] <= node.range[0] && node.range[1] <= func.range[1]) {
return false;
}
}
return true;
}
/**
* Checks whether the node is in type annotation.
* @returns `true` if the node is in type annotation.
*/
function isInTypeAnnotation(node) {
for (const typeAnnotation of typeAnnotations) {
if (typeAnnotation.range[0] <= node.range[0] && node.range[1] <= typeAnnotation.range[1]) {
return true;
}
}
return false;
}
/**
* Iterate over the references of modules that can check the browser environment.
*/
function* iterateBrowserCheckerModuleReferences() {
for (const ref of referenceTracker.iterateEsmReferences({
'esm-env': {
[ReferenceTracker.ESM]: true,
// See https://www.npmjs.com/package/esm-env
BROWSER: {
[ReferenceTracker.READ]: true
}
},
'$app/environment': {
[ReferenceTracker.ESM]: true,
// See https://svelte.dev/docs/kit/$app-environment#browser
browser: {
[ReferenceTracker.READ]: true
}
}
})) {
if (ref.node.type === 'Identifier' || ref.node.type === 'MemberExpression') {
yield ref.node;
}
else if (ref.node.type === 'ImportSpecifier') {
const variable = findVariable(context, ref.node.local);
if (variable) {
for (const reference of variable.references) {
if (reference.isRead() && reference.identifier.type === 'Identifier') {
yield reference.identifier;
}
}
}
}
}
}
/**
* Iterate over the used references of global variables.
*/
function* iterateBrowserGlobalReferences() {
yield* referenceTracker.iterateGlobalReferences(Object.fromEntries(blowerGlobals.map((name) => [
name,
{
[ReferenceTracker.READ]: true
}
])));
}
/**
* If the node is a reference used in a guard clause that checks if the node is in a browser environment,
* it returns information about the expression that checks if the browser variable is available.
* @returns The guard info.
*/
function getGuardCheckerFromReference(node) {
const parent = node.parent;
if (!parent)
return null;
if (parent.type === 'BinaryExpression') {
if (parent.operator === 'instanceof' &&
parent.left === node &&
node.type === 'MemberExpression') {
// e.g. if (globalThis.window instanceof X)
return getGuardChecker({ node: parent });
}
const operand = parent.left === node ? parent.right : parent.right === node ? parent.left : null;
if (!operand)
return null;
const staticValue = getStaticValue(operand, getScope(context, operand));
if (!staticValue)
return null;
if (staticValue.value === undefined && node.type === 'MemberExpression') {
if (parent.operator === '!==' || parent.operator === '!=') {
// e.g. if (globalThis.window !== undefined), if (globalThis.window != undefined)
return getGuardChecker({ node: parent });
}
else if (parent.operator === '===' || parent.operator === '==') {
// e.g. if (globalThis.window === undefined), if (globalThis.window == undefined)
return getGuardChecker({ node: parent, not: true });
}
}
else if (staticValue.value === null && node.type === 'MemberExpression') {
if (parent.operator === '!=') {
// e.g. if (globalThis.window != null)
return getGuardChecker({ node: parent });
}
else if (parent.operator === '==') {
// e.g. if (globalThis.window == null)
return getGuardChecker({ node: parent, not: true });
}
}
return null;
}
if (parent.type === 'UnaryExpression' &&
parent.operator === 'typeof' &&
parent.argument === node) {
const pp = parent.parent;
if (!pp || pp.type !== 'BinaryExpression') {
return null;
}
const staticValue = getStaticValue(pp.left === parent ? pp.right : pp.left, getScope(context, node));
if (!staticValue)
return null;
if (staticValue.value !== 'undefined' && staticValue.value !== 'object') {
return null;
}
if (pp.operator === '!==' || pp.operator === '!=') {
if (staticValue.value === 'undefined') {
// e.g. if (typeof window !== "undefined"), if (typeof window != "undefined")
return getGuardChecker({ node: pp });
}
// e.g. if (typeof window !== "object"), if (typeof window != "object")
return getGuardChecker({ node: pp, not: true });
}
else if (pp.operator === '===' || pp.operator === '==') {
if (staticValue.value === 'undefined') {
// e.g. if (typeof window === "undefined"), if (typeof window == "undefined")
return getGuardChecker({ node: pp, not: true });
}
// e.g. if (typeof window === "object"), if (typeof window == "object")
return getGuardChecker({ node: pp });
}
return null;
}
if (node.type === 'MemberExpression') {
if (((parent.type === 'CallExpression' && parent.callee === node) ||
(parent.type === 'MemberExpression' && parent.object === node)) &&
parent.optional) {
// e.g. globalThis.location?.href
return (n) => n === node;
}
// e.g. if (globalThis.window)
return getGuardChecker({ node });
}
return null;
}
/**
* If the node is a guard clause checking,
* returns a function to check if the node is available.
*/
function getGuardChecker(guardInfo) {
const parent = guardInfo.node.parent;
if (!parent)
return null;
if (parent.type === 'ConditionalExpression') {
const block = guardInfo.not ? parent.alternate : parent.consequent;
return (n) => block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
}
if (parent.type === 'UnaryExpression' && parent.operator === '!') {
return getGuardChecker({ not: !guardInfo.not, node: parent });
}
if (parent.type === 'SvelteIfBlock' && parent.expression === guardInfo.node) {
if (!guardInfo.not) {
if (parent.children.length === 0) {
return null; // No block to check
}
const first = parent.children[0];
const last = parent.children.at(-1);
return (n) => first.range[0] <= n.range[0] && n.range[1] <= last.range[1];
}
// not
if (parent.else) {
const block = parent.else;
return (n) => block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
}
return null;
}
if (parent.type === 'IfStatement' && parent.test === guardInfo.node) {
if (!guardInfo.not) {
const block = parent.consequent;
return (n) => block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
}
if (parent.alternate) {
const block = parent.alternate;
return (n) => block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
}
if (!hasJumpStatementInAllPath(parent.consequent)) {
return null;
}
const pp = parent.parent;
if (!pp || (pp.type !== 'BlockStatement' && pp.type !== 'Program')) {
return null;
}
const start = parent.range[1];
const end = pp.range[1];
return (n) => start <= n.range[0] && n.range[1] <= end;
}
if (parent.type === 'LogicalExpression') {
if (!guardInfo.not && parent.operator === '&&') {
const parentChecker = getGuardChecker({ not: guardInfo.not, node: parent });
if (parent.left === guardInfo.node) {
const block = parent.right;
return (n) => {
if (parentChecker?.(n)) {
return true;
}
return block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
};
}
return parentChecker;
}
if (guardInfo.not && parent.operator === '||') {
return getGuardChecker({ not: guardInfo.not, node: parent });
}
}
return null;
}
}
});
/**
* Get the list of browser-specific globals.
*/
function getBrowserGlobals() {
const nodeGlobals = new Set(Object.keys(globals.node));
return [
'window',
'document',
...Object.keys(globals.browser).filter((name) => !nodeGlobals.has(name))
];
}
/**
* Checks whether all paths of a given statement have jump statements.
* @param {Statement} statement
* @returns {boolean}
*/
function hasJumpStatementInAllPath(statement) {
if (isJumpStatement(statement)) {
return true;
}
if (statement.type === 'BlockStatement') {
return statement.body.some(hasJumpStatementInAllPath);
}
if (statement.type === 'IfStatement') {
if (!statement.alternate) {
return false;
}
return (hasJumpStatementInAllPath(statement.alternate) &&
hasJumpStatementInAllPath(statement.consequent));
}
return false;
}
/**
* Checks whether the given statement is a jump statement.
* @param {Statement} statement
* @returns {statement is JumpStatement}
*/
function isJumpStatement(statement) {
return (statement.type === 'ReturnStatement' ||
statement.type === 'ContinueStatement' ||
statement.type === 'BreakStatement');
}