UNPKG

eslint-plugin-svelte

Version:
398 lines (397 loc) 17.1 kB
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'); }