-
-
Notifications
You must be signed in to change notification settings - Fork 404
Add no-single-promise-in-promise-methods
rule
#2258
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
sindresorhus
merged 34 commits into
sindresorhus:main
from
Clement398:no-single-promise-in-promise-methods
Feb 23, 2024
Merged
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
38306bc
Add `no-single-promise-in-promise-methods` rule
Clement398 a728a1a
Use snapshot test
fisker 7030d7f
More tests
fisker 1f24588
Refactor
fisker 92454c7
More tests
fisker a2647ae
Not limit types
Clement398 7869878
Add leading semicolon
Clement398 60467a6
Add tests
Clement398 26e557a
Update error message
Clement398 8417930
Update error message
Clement398 345b637
Rename functions
Clement398 a32688f
Fix .then() error
Clement398 8893487
Update no-single-promise-in-promise-methods.md
sindresorhus e28b11a
Update tests
Clement398 e15154e
Update description message
Clement398 d74d45f
Update no-single-promise-in-promise-methods.md and tests
Clement398 377da6e
Fix exponentiation operator error
Clement398 ebd4708
Add `shouldAddParenthesesToAwaitExpressionArgument`
fisker 062002e
Add todo
fisker 8ed5eda
Fix
fisker 548f94f
Rename message id
fisker a32c16c
Improve `switchToPromiseResolve`
fisker b5d870c
Improve `unwrapNonAwaitedCallExpression`
fisker 86b3b4b
Update message
fisker 5a1232c
Stricter
fisker bd06124
Tweak tests
fisker 72f0a13
More tests
fisker d42c1d7
Update docs
fisker 04bf100
More tests
fisker 5e82ac7
Update message
fisker 5799fd3
Update docs
fisker ca335f6
Loosely hole check
fisker eed65b6
More tests
fisker c97843e
More
fisker File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
# Disallow passing single-element arrays to `Promise` methods | ||
|
||
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs). | ||
|
||
🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions). | ||
|
||
<!-- end auto-generated rule header --> | ||
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` --> | ||
|
||
Passing a single-element array to `Promise.all()`, `Promise.any()`, or `Promise.race()` is likely a mistake. | ||
|
||
## Fail | ||
|
||
```js | ||
const foo = await Promise.all([promise]); | ||
``` | ||
|
||
```js | ||
const foo = await Promise.any([promise]); | ||
``` | ||
|
||
```js | ||
const foo = await Promise.race([promise]); | ||
``` | ||
|
||
```js | ||
const promise = Promise.all([nonPromise]); | ||
``` | ||
|
||
## Pass | ||
|
||
```js | ||
const foo = await promise; | ||
``` | ||
|
||
```js | ||
const promise = Promise.resolve(nonPromise); | ||
``` | ||
|
||
```js | ||
const foo = await Promise.all(promises); | ||
``` | ||
|
||
```js | ||
const foo = await Promise.any([promise, anotherPromise]); | ||
``` | ||
|
||
```js | ||
const [{value: foo, reason: error}] = await Promise.allSettled([promise]); | ||
``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,167 @@ | ||
'use strict'; | ||
const { | ||
isCommaToken, | ||
} = require('@eslint-community/eslint-utils'); | ||
const {isMethodCall} = require('./ast/index.js'); | ||
const { | ||
getParenthesizedText, | ||
isParenthesized, | ||
needsSemicolon, | ||
shouldAddParenthesesToAwaitExpressionArgument, | ||
} = require('./utils/index.js'); | ||
|
||
const MESSAGE_ID_ERROR = 'no-single-promise-in-promise-methods/error'; | ||
const MESSAGE_ID_SUGGESTION_UNWRAP = 'no-single-promise-in-promise-methods/unwrap'; | ||
const MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE = 'no-single-promise-in-promise-methods/use-promise-resolve'; | ||
const messages = { | ||
[MESSAGE_ID_ERROR]: 'Wrapping single-element array with `Promise.{{method}}()` is unnecessary.', | ||
[MESSAGE_ID_SUGGESTION_UNWRAP]: 'Use the value directly.', | ||
[MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE]: 'Switch to `Promise.resolve(…)`.', | ||
}; | ||
const METHODS = ['all', 'any', 'race']; | ||
|
||
const isPromiseMethodCallWithSingleElementArray = node => | ||
isMethodCall(node, { | ||
object: 'Promise', | ||
methods: METHODS, | ||
optionalMember: false, | ||
optionalCall: false, | ||
argumentsLength: 1, | ||
}) | ||
&& node.arguments[0].type === 'ArrayExpression' | ||
&& node.arguments[0].elements.length === 1 | ||
&& node.arguments[0].elements[0] | ||
&& node.arguments[0].elements[0].type !== 'SpreadElement'; | ||
|
||
const unwrapAwaitedCallExpression = (callExpression, sourceCode) => fixer => { | ||
const [promiseNode] = callExpression.arguments[0].elements; | ||
let text = getParenthesizedText(promiseNode, sourceCode); | ||
|
||
if ( | ||
!isParenthesized(promiseNode, sourceCode) | ||
&& shouldAddParenthesesToAwaitExpressionArgument(promiseNode) | ||
) { | ||
text = `(${text})`; | ||
} | ||
|
||
// The next node is already behind a `CallExpression`, there should be no ASI problem | ||
|
||
return fixer.replaceText(callExpression, text); | ||
}; | ||
|
||
const unwrapNonAwaitedCallExpression = (callExpression, sourceCode) => fixer => { | ||
const [promiseNode] = callExpression.arguments[0].elements; | ||
let text = getParenthesizedText(promiseNode, sourceCode); | ||
|
||
if ( | ||
!isParenthesized(promiseNode, sourceCode) | ||
// Since the original call expression can be anywhere, it's hard to tell if the promise | ||
// need to be parenthesized, but it's safe to add parentheses | ||
&& !( | ||
// Known cases that not need parentheses | ||
promiseNode.type === 'Identifier' | ||
|| promiseNode.type === 'MemberExpression' | ||
) | ||
) { | ||
text = `(${text})`; | ||
} | ||
|
||
const previousToken = sourceCode.getTokenBefore(callExpression); | ||
if (needsSemicolon(previousToken, sourceCode, text)) { | ||
text = `;${text}`; | ||
} | ||
|
||
return fixer.replaceText(callExpression, text); | ||
}; | ||
|
||
const switchToPromiseResolve = (callExpression, sourceCode) => function * (fixer) { | ||
/* | ||
``` | ||
Promise.all([promise,]) | ||
// ^^^ methodNameNode | ||
``` | ||
*/ | ||
const methodNameNode = callExpression.callee.property; | ||
yield fixer.replaceText(methodNameNode, 'resolve'); | ||
|
||
const [arrayExpression] = callExpression.arguments; | ||
/* | ||
``` | ||
Promise.all([promise,]) | ||
// ^ openingBracketToken | ||
``` | ||
*/ | ||
const openingBracketToken = sourceCode.getFirstToken(arrayExpression); | ||
/* | ||
``` | ||
Promise.all([promise,]) | ||
// ^ penultimateToken | ||
// ^ closingBracketToken | ||
``` | ||
*/ | ||
const [ | ||
penultimateToken, | ||
closingBracketToken, | ||
] = sourceCode.getLastTokens(arrayExpression, 2); | ||
|
||
yield fixer.remove(openingBracketToken); | ||
yield fixer.remove(closingBracketToken); | ||
|
||
if (isCommaToken(penultimateToken)) { | ||
yield fixer.remove(penultimateToken); | ||
} | ||
}; | ||
|
||
/** @param {import('eslint').Rule.RuleContext} context */ | ||
const create = context => ({ | ||
CallExpression(callExpression) { | ||
if (!isPromiseMethodCallWithSingleElementArray(callExpression)) { | ||
return; | ||
} | ||
|
||
const problem = { | ||
node: callExpression.arguments[0], | ||
messageId: MESSAGE_ID_ERROR, | ||
data: { | ||
method: callExpression.callee.property.name, | ||
}, | ||
}; | ||
|
||
const {sourceCode} = context; | ||
|
||
if ( | ||
callExpression.parent.type === 'AwaitExpression' | ||
&& callExpression.parent.argument === callExpression | ||
) { | ||
problem.fix = unwrapAwaitedCallExpression(callExpression, sourceCode); | ||
return problem; | ||
} | ||
|
||
problem.suggest = [ | ||
{ | ||
messageId: MESSAGE_ID_SUGGESTION_UNWRAP, | ||
fix: unwrapNonAwaitedCallExpression(callExpression, sourceCode), | ||
}, | ||
{ | ||
messageId: MESSAGE_ID_SUGGESTION_SWITCH_TO_PROMISE_RESOLVE, | ||
fix: switchToPromiseResolve(callExpression, sourceCode), | ||
}, | ||
]; | ||
|
||
return problem; | ||
}, | ||
}); | ||
|
||
/** @type {import('eslint').Rule.RuleModule} */ | ||
module.exports = { | ||
create, | ||
meta: { | ||
type: 'suggestion', | ||
docs: { | ||
description: 'Disallow passing single-element arrays to `Promise` methods.', | ||
}, | ||
fixable: 'code', | ||
hasSuggestions: true, | ||
messages, | ||
}, | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
21 changes: 21 additions & 0 deletions
21
rules/utils/should-add-parentheses-to-await-expression-argument.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
'use strict'; | ||
|
||
/** | ||
Check if parentheses should be added to a `node` when it's used as `argument` of `AwaitExpression`. | ||
|
||
@param {Node} node - The AST node to check. | ||
@returns {boolean} | ||
*/ | ||
function shouldAddParenthesesToAwaitExpressionArgument(node) { | ||
return ( | ||
node.type === 'SequenceExpression' | ||
|| node.type === 'YieldExpression' | ||
|| node.type === 'ArrowFunctionExpression' | ||
|| node.type === 'ConditionalExpression' | ||
|| node.type === 'AssignmentExpression' | ||
|| node.type === 'LogicalExpression' | ||
|| node.type === 'BinaryExpression' | ||
); | ||
} | ||
|
||
module.exports = shouldAddParenthesesToAwaitExpressionArgument; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import outdent from 'outdent'; | ||
import {getTester} from './utils/test.mjs'; | ||
|
||
const {test} = getTester(import.meta); | ||
|
||
// `await`ed | ||
test.snapshot({ | ||
valid: [ | ||
], | ||
invalid: [ | ||
'await Promise.all([(0, promise)])', | ||
'async function * foo() {await Promise.all([yield promise])}', | ||
'async function * foo() {await Promise.all([yield* promise])}', | ||
'await Promise.all([() => promise,],)', | ||
'await Promise.all([a ? b : c,],)', | ||
'await Promise.all([x ??= y,],)', | ||
'await Promise.all([x ||= y,],)', | ||
'await Promise.all([x &&= y,],)', | ||
'await Promise.all([x |= y,],)', | ||
'await Promise.all([x ^= y,],)', | ||
'await Promise.all([x ??= y,],)', | ||
'await Promise.all([x ||= y,],)', | ||
'await Promise.all([x &&= y,],)', | ||
'await Promise.all([x | y,],)', | ||
'await Promise.all([x ^ y,],)', | ||
'await Promise.all([x & y,],)', | ||
'await Promise.all([x !== y,],)', | ||
'await Promise.all([x == y,],)', | ||
'await Promise.all([x in y,],)', | ||
'await Promise.all([x >>> y,],)', | ||
'await Promise.all([x + y,],)', | ||
'await Promise.all([x / y,],)', | ||
'await Promise.all([x ** y,],)', | ||
'await Promise.all([promise,],)', | ||
'await Promise.all([getPromise(),],)', | ||
'await Promise.all([promises[0],],)', | ||
'await Promise.all([await promise])', | ||
'await Promise.any([promise])', | ||
'await Promise.race([promise])', | ||
'await Promise.all([new Promise(() => {})])', | ||
'+await Promise.all([+1])', | ||
|
||
// ASI, `Promise.all()` is not really `await`ed | ||
outdent` | ||
await Promise.all([(x,y)]) | ||
[0].toString() | ||
`, | ||
], | ||
}); | ||
|
||
// Not `await`ed | ||
test.snapshot({ | ||
valid: [ | ||
'Promise.all([promise, anotherPromise])', | ||
'Promise.all(notArrayLiteral)', | ||
'Promise.all([...promises])', | ||
'Promise.any([promise, anotherPromise])', | ||
'Promise.race([promise, anotherPromise])', | ||
'Promise.notListedMethod([promise])', | ||
'Promise[all]([promise])', | ||
'Promise.all([,])', | ||
'NotPromise.all([promise])', | ||
'Promise?.all([promise])', | ||
'Promise.all?.([promise])', | ||
'Promise.all(...[promise])', | ||
'Promise.all([promise], extraArguments)', | ||
'Promise.all()', | ||
'new Promise.all([promise])', | ||
|
||
// We are not checking these cases | ||
'globalThis.Promise.all([promise])', | ||
'Promise["all"]([promise])', | ||
|
||
// This can't be checked | ||
'Promise.allSettled([promise])', | ||
], | ||
invalid: [ | ||
'Promise.all([promise,],)', | ||
outdent` | ||
foo | ||
Promise.all([(0, promise),],) | ||
`, | ||
outdent` | ||
foo | ||
Promise.all([[array][0],],) | ||
`, | ||
'Promise.all([promise]).then()', | ||
'Promise.all([1]).then()', | ||
'Promise.all([1.]).then()', | ||
'Promise.all([.1]).then()', | ||
'Promise.all([(0, promise)]).then()', | ||
'const _ = () => Promise.all([ a ?? b ,],)', | ||
'Promise.all([ {a} = 1 ,],)', | ||
'Promise.all([ function () {} ,],)', | ||
'Promise.all([ class {} ,],)', | ||
'Promise.all([ new Foo ,],).then()', | ||
'Promise.all([ new Foo ,],).toString', | ||
'foo(Promise.all([promise]))', | ||
'Promise.all([promise]).foo = 1', | ||
'Promise.all([promise])[0] ||= 1', | ||
'Promise.all([undefined]).then()', | ||
'Promise.all([null]).then()', | ||
], | ||
}); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.