diff --git a/docs/rules/explicit-timer-delay.md b/docs/rules/explicit-timer-delay.md new file mode 100644 index 0000000000..4349680a53 --- /dev/null +++ b/docs/rules/explicit-timer-delay.md @@ -0,0 +1,77 @@ +# Enforce or disallow explicit `delay` argument for `setTimeout()` and `setInterval()` + +💼 This rule is enabled in the following [configs](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config): ✅ `recommended`, ☑️ `unopinionated`. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + + +When using [`setTimeout()`](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout) or [`setInterval()`](https://developer.mozilla.org/en-US/docs/Web/API/setInterval), the `delay` parameter is optional and defaults to `0`. This rule allows you to enforce whether the `delay` argument should always be explicitly provided or omitted when it's `0`. + +This provides flexibility for different team preferences: + +- `"always"` (default): Require explicit `delay` argument for clarity +- `"never"`: Disallow explicit `0` delay (prefer implicit default) + +## Examples + +### With `"always"` (default) + +```js +// ❌ +setTimeout(() => console.log('Hello')); +setInterval(callback); +window.setTimeout(() => console.log('Hello')); +globalThis.setInterval(callback); + +// ✅ +setTimeout(() => console.log('Hello'), 0); +setInterval(callback, 0); +setTimeout(() => console.log('Hello'), 1000); +window.setTimeout(() => console.log('Hello'), 0); +globalThis.setInterval(callback, 100); +``` + +### With `"never"` + +```js +// ❌ +setTimeout(() => console.log('Hello'), 0); +setInterval(callback, 0); +window.setTimeout(() => console.log('Hello'), 0); +globalThis.setInterval(callback, 0); + +// ✅ +setTimeout(() => console.log('Hello')); +setInterval(callback); +setTimeout(() => console.log('Hello'), 1000); +window.setTimeout(() => console.log('Hello')); +globalThis.setInterval(callback, 100); +``` + +## Options + +Type: `string` + +Values: `"always"` (default) | `"never"` + +### `"always"` + +Requires an explicit `delay` argument for all `setTimeout()` and `setInterval()` calls. + +```json +{ + "unicorn/explicit-timer-delay": ["error", "always"] +} +``` + +### `"never"` + +Disallows an explicit `delay` argument when it's `0`. Non-zero delays are still allowed. + +```json +{ + "unicorn/explicit-timer-delay": ["error", "never"] +} +``` diff --git a/readme.md b/readme.md index 183c2c0379..6a29a4b938 100644 --- a/readme.md +++ b/readme.md @@ -71,6 +71,7 @@ export default [ | [escape-case](docs/rules/escape-case.md) | Require escape sequences to use uppercase or lowercase values. | ✅ ☑️ | 🔧 | | | [expiring-todo-comments](docs/rules/expiring-todo-comments.md) | Add expiration conditions to TODO comments. | ✅ ☑️ | | | | [explicit-length-check](docs/rules/explicit-length-check.md) | Enforce explicitly comparing the `length` or `size` property of a value. | ✅ | 🔧 | 💡 | +| [explicit-timer-delay](docs/rules/explicit-timer-delay.md) | Enforce or disallow explicit `delay` argument for `setTimeout()` and `setInterval()`. | ✅ ☑️ | 🔧 | | | [filename-case](docs/rules/filename-case.md) | Enforce a case style for filenames. | ✅ | | | | [import-style](docs/rules/import-style.md) | Enforce specific import styles per module. | ✅ ☑️ | | | | [new-for-builtins](docs/rules/new-for-builtins.md) | Enforce the use of `new` for all builtins, except `String`, `Number`, `Boolean`, `Symbol` and `BigInt`. | ✅ ☑️ | 🔧 | 💡 | diff --git a/rules/explicit-timer-delay.js b/rules/explicit-timer-delay.js new file mode 100644 index 0000000000..726009b07b --- /dev/null +++ b/rules/explicit-timer-delay.js @@ -0,0 +1,144 @@ +import {isLiteral} from './ast/index.js'; + +const MODE_ALWAYS = 'always'; +const MODE_NEVER = 'never'; + +const MESSAGE_ID_MISSING_DELAY = 'missing-delay'; +const MESSAGE_ID_REDUNDANT_DELAY = 'redundant-delay'; + +const messages = { + [MESSAGE_ID_MISSING_DELAY]: '`{{name}}` should have an explicit delay argument.', + [MESSAGE_ID_REDUNDANT_DELAY]: '`{{name}}` should not have an explicit delay of `0`.', +}; + +const timerFunctions = new Set(['setTimeout', 'setInterval']); + +/** +Check if a call expression is a timer function call. +@param {import('estree').CallExpression} node - The call expression node. +@param {import('eslint').SourceCode} sourceCode +@returns {{isTimer: boolean, name?: string}} Object with isTimer flag and function name. +*/ +const checkTimerCall = (node, sourceCode) => { + const {callee} = node; + + if ( + callee.type === 'Identifier' + && timerFunctions.has(callee.name) + && sourceCode.isGlobalReference(callee) + ) { + return {isTimer: true, name: callee.name}; + } + + if ( + callee.type === 'MemberExpression' + && !callee.computed + && callee.property.type === 'Identifier' + && timerFunctions.has(callee.property.name) + ) { + const {object} = callee; + + if ( + object.type === 'Identifier' + && sourceCode.isGlobalReference(object) + ) { + return {isTimer: true, name: callee.property.name}; + } + + return {isTimer: false}; + } + + return {isTimer: false}; +}; + +/** + Check if the delay argument is explicitly zero. + @param {import('estree').Node} node - The argument node. + @returns {boolean} True if the argument is zero. + */ +const isZeroDelay = node => + isLiteral(node, 0) + || (node.type === 'UnaryExpression' && node.operator === '-' && isLiteral(node.argument, 0)); + +/** @param {import('eslint').Rule.RuleContext} context */ +const create = context => { + const mode = context.options[0] || MODE_ALWAYS; + const {sourceCode} = context; + + context.on('CallExpression', node => { + if (node.optional) { + return; + } + + const {isTimer, name} = checkTimerCall(node, sourceCode); + + if (!isTimer) { + return; + } + + const {arguments: arguments_} = node; + const hasDelayArgument = arguments_.length >= 2; + + if (mode === MODE_ALWAYS && !hasDelayArgument) { + if (arguments_.length === 0) { + return; + } + + const problem = { + node, + messageId: MESSAGE_ID_MISSING_DELAY, + data: {name}, + }; + + const [firstArgument] = arguments_; + if (firstArgument && firstArgument.type !== 'SpreadElement') { + problem.fix = fixer => fixer.insertTextAfter(firstArgument, ', 0'); + } + + return problem; + } + + if (mode === MODE_NEVER && hasDelayArgument) { + const delayArgument = arguments_[1]; + + if (isZeroDelay(delayArgument)) { + return { + node: delayArgument, + messageId: MESSAGE_ID_REDUNDANT_DELAY, + data: {name}, + fix(fixer) { + const firstArgument = arguments_[0]; + const [, firstArgumentEnd] = sourceCode.getRange(firstArgument); + const [delayArgumentStart] = sourceCode.getRange(delayArgument); + + return fixer.removeRange([firstArgumentEnd, delayArgumentStart + sourceCode.getText(delayArgument).length]); + }, + }; + } + } + }); +}; + +const schema = [ + { + enum: [MODE_ALWAYS, MODE_NEVER], + }, +]; + +/** @type {import('eslint').Rule.RuleModule} */ +const config = { + create, + meta: { + type: 'suggestion', + docs: { + description: 'Enforce or disallow explicit `delay` argument for `setTimeout()` and `setInterval()`.', + recommended: 'unopinionated', + }, + fixable: 'code', + schema, + defaultOptions: [MODE_ALWAYS], + messages, + }, +}; + +export default config; diff --git a/rules/index.js b/rules/index.js index 0a76231421..ef6be8023e 100644 --- a/rules/index.js +++ b/rules/index.js @@ -14,6 +14,7 @@ export {default as 'error-message'} from './error-message.js'; export {default as 'escape-case'} from './escape-case.js'; export {default as 'expiring-todo-comments'} from './expiring-todo-comments.js'; export {default as 'explicit-length-check'} from './explicit-length-check.js'; +export {default as 'explicit-timer-delay'} from './explicit-timer-delay.js'; export {default as 'filename-case'} from './filename-case.js'; export {default as 'import-style'} from './import-style.js'; export {default as 'new-for-builtins'} from './new-for-builtins.js'; diff --git a/test/explicit-timer-delay.js b/test/explicit-timer-delay.js new file mode 100644 index 0000000000..eabca59b8b --- /dev/null +++ b/test/explicit-timer-delay.js @@ -0,0 +1,221 @@ +import outdent from 'outdent'; +import {getTester} from './utils/test.js'; + +const {test} = getTester(import.meta); + +const MESSAGE_ID_MISSING_DELAY = 'missing-delay'; +const MESSAGE_ID_REDUNDANT_DELAY = 'redundant-delay'; + +test({ + valid: [ + 'setTimeout(() => console.log("Hello"), 0);', + 'setInterval(callback, 0);', + 'setTimeout(() => console.log("Hello"), 1000);', + 'setInterval(callback, 100);', + 'window.setTimeout(() => console.log("Hello"), 0);', + 'globalThis.setInterval(callback, 0);', + 'global.setTimeout(() => {}, 0);', + 'setTimeout(callback, 0, arg1, arg2);', + 'setInterval(callback, 100, arg1);', + outdent` + import {setTimeout as delay} from 'node:timers/promises'; + + await delay(100); + `, + 'setTimeout?.(() => {});', + 'window.setTimeout?.(callback);', + 'setTimeout();', + 'customSetTimeout(callback);', + 'obj.customSetTimeout(callback);', + { + code: 'setTimeout(() => console.log("Hello"));', + options: ['never'], + }, + { + code: 'setInterval(callback);', + options: ['never'], + }, + { + code: 'window.setTimeout(() => console.log("Hello"));', + options: ['never'], + }, + { + code: 'globalThis.setInterval(callback);', + options: ['never'], + }, + { + code: 'setTimeout(() => console.log("Hello"), 1000);', + options: ['never'], + }, + { + code: 'setInterval(callback, 100);', + options: ['never'], + }, + { + code: 'setTimeout(callback, 500, arg1);', + options: ['never'], + }, + ], + invalid: [ + { + code: 'setTimeout(() => console.log("Hello"));', + output: 'setTimeout(() => console.log("Hello"), 0);', + errors: [{ + messageId: MESSAGE_ID_MISSING_DELAY, + data: {name: 'setTimeout'}, + }], + }, + { + code: 'setInterval(callback);', + output: 'setInterval(callback, 0);', + errors: [{ + messageId: MESSAGE_ID_MISSING_DELAY, + data: {name: 'setInterval'}, + }], + }, + { + code: 'window.setTimeout(() => console.log("Hello"));', + output: 'window.setTimeout(() => console.log("Hello"), 0);', + errors: [{ + messageId: MESSAGE_ID_MISSING_DELAY, + data: {name: 'setTimeout'}, + }], + }, + { + code: 'globalThis.setInterval(callback);', + output: 'globalThis.setInterval(callback, 0);', + errors: [{ + messageId: MESSAGE_ID_MISSING_DELAY, + data: {name: 'setInterval'}, + }], + }, + { + code: 'global.setTimeout(fn);', + output: 'global.setTimeout(fn, 0);', + errors: [{ + messageId: MESSAGE_ID_MISSING_DELAY, + data: {name: 'setTimeout'}, + }], + }, + { + code: outdent` + setTimeout( + () => console.log("Hello") + ); + `, + output: outdent` + setTimeout( + () => console.log("Hello"), 0 + ); + `, + errors: [{messageId: MESSAGE_ID_MISSING_DELAY}], + }, + { + code: outdent` + setInterval( + callback + ); + `, + output: outdent` + setInterval( + callback, 0 + ); + `, + errors: [{messageId: MESSAGE_ID_MISSING_DELAY}], + }, + { + code: 'setTimeout(...args);', + errors: [{ + messageId: MESSAGE_ID_MISSING_DELAY, + data: {name: 'setTimeout'}, + }], + }, + { + code: 'setTimeout(() => console.log("Hello"), 0);', + output: 'setTimeout(() => console.log("Hello"));', + options: ['never'], + errors: [{ + messageId: MESSAGE_ID_REDUNDANT_DELAY, + data: {name: 'setTimeout'}, + }], + }, + { + code: 'setInterval(callback, 0);', + output: 'setInterval(callback);', + options: ['never'], + errors: [{ + messageId: MESSAGE_ID_REDUNDANT_DELAY, + data: {name: 'setInterval'}, + }], + }, + { + code: 'window.setTimeout(() => console.log("Hello"), 0);', + output: 'window.setTimeout(() => console.log("Hello"));', + options: ['never'], + errors: [{ + messageId: MESSAGE_ID_REDUNDANT_DELAY, + data: {name: 'setTimeout'}, + }], + }, + { + code: 'globalThis.setInterval(callback, 0);', + output: 'globalThis.setInterval(callback);', + options: ['never'], + errors: [{ + messageId: MESSAGE_ID_REDUNDANT_DELAY, + data: {name: 'setInterval'}, + }], + }, + { + code: 'global.setTimeout(fn, 0);', + output: 'global.setTimeout(fn);', + options: ['never'], + errors: [{ + messageId: MESSAGE_ID_REDUNDANT_DELAY, + data: {name: 'setTimeout'}, + }], + }, + { + code: 'setTimeout(() => console.log("Hello"), -0);', + output: 'setTimeout(() => console.log("Hello"));', + options: ['never'], + errors: [{messageId: MESSAGE_ID_REDUNDANT_DELAY}], + }, + { + code: outdent` + setTimeout( + () => console.log("Hello"), + 0 + ); + `, + output: outdent` + setTimeout( + () => console.log("Hello") + ); + `, + options: ['never'], + errors: [{messageId: MESSAGE_ID_REDUNDANT_DELAY}], + }, + { + code: outdent` + setInterval( + callback, + 0 + ); + `, + output: outdent` + setInterval( + callback + ); + `, + options: ['never'], + errors: [{messageId: MESSAGE_ID_REDUNDANT_DELAY}], + }, + { + code: 'setTimeout(callback, 0, arg1, arg2);', + output: 'setTimeout(callback, arg1, arg2);', + options: ['never'], + errors: [{messageId: MESSAGE_ID_REDUNDANT_DELAY}], + }, + ], +});