diff --git a/packages/eslint-plugin/docs/rules/await-thenable.mdx b/packages/eslint-plugin/docs/rules/await-thenable.mdx index f9905bfcf80a..c4006c816a56 100644 --- a/packages/eslint-plugin/docs/rules/await-thenable.mdx +++ b/packages/eslint-plugin/docs/rules/await-thenable.mdx @@ -49,7 +49,7 @@ This rule also inspects [`for await...of` statements](https://developer.mozilla. While `for await...of` can be used with synchronous iterables, and it will await each promise produced by the iterable, it is inadvisable to do so. There are some tiny nuances that you may want to consider. -The biggest difference between using `for await...of` and using `for...of` (plus awaiting each result yourself) is error handling. +The biggest difference between using `for await...of` and using `for...of` (apart from awaiting each result yourself) is error handling. When an error occurs within the loop body, `for await...of` does _not_ close the original sync iterable, while `for...of` does. For detailed examples of this, see the [MDN documentation on using `for await...of` with sync-iterables](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of#iterating_over_sync_iterables_and_generators). @@ -120,6 +120,62 @@ async function validUseOfForAwaitOnAsyncIterable() { +## Explicit Resource Management (`await using` Statements) + +This rule also inspects [`await using` statements](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-2.html#using-declarations-and-explicit-resource-management). +If the disposable being used is not async-disposable, an `await using` statement is unnecessary. + +### Examples + + + + +```ts +function makeSyncDisposable(): Disposable { + return { + [Symbol.dispose](): void { + // Dispose of the resource + }, + }; +} + +async function shouldNotAwait() { + await using resource = makeSyncDisposable(); +} +``` + + + + +```ts +function makeSyncDisposable(): Disposable { + return { + [Symbol.dispose](): void { + // Dispose of the resource + }, + }; +} + +async function shouldNotAwait() { + using resource = makeSyncDisposable(); +} + +function makeAsyncDisposable(): AsyncDisposable { + return { + async [Symbol.asyncDispose](): Promise { + // Dispose of the resource asynchronously + }, + }; +} + +async function shouldAwait() { + await using resource = makeAsyncDisposable(); +} +``` + + + + ## When Not To Use It If you want to allow code to `await` non-Promise values. diff --git a/packages/eslint-plugin/src/rules/await-thenable.ts b/packages/eslint-plugin/src/rules/await-thenable.ts index d71efd65139d..c1b50766e3ae 100644 --- a/packages/eslint-plugin/src/rules/await-thenable.ts +++ b/packages/eslint-plugin/src/rules/await-thenable.ts @@ -4,6 +4,7 @@ import * as tsutils from 'ts-api-utils'; import { createRule, + getFixOrSuggest, getParserServices, isAwaitKeyword, isTypeAnyType, @@ -15,8 +16,9 @@ import { getForStatementHeadLoc } from '../util/getForStatementHeadLoc'; type MessageId = | 'await' + | 'awaitUsingOfNonAsyncDisposable' | 'convertToOrdinaryFor' - | 'forAwaitOfNonThenable' + | 'forAwaitOfNonAsyncIterable' | 'removeAwait'; export default createRule<[], MessageId>({ @@ -31,8 +33,10 @@ export default createRule<[], MessageId>({ hasSuggestions: true, messages: { await: 'Unexpected `await` of a non-Promise (non-"Thenable") value.', + awaitUsingOfNonAsyncDisposable: + 'Unexpected `await using` of a value that is not async disposable.', convertToOrdinaryFor: 'Convert to an ordinary `for...of` loop.', - forAwaitOfNonThenable: + forAwaitOfNonAsyncIterable: 'Unexpected `for await...of` of a value that is not async iterable.', removeAwait: 'Remove unnecessary `await`.', }, @@ -80,21 +84,21 @@ export default createRule<[], MessageId>({ return; } - const asyncIteratorSymbol = tsutils + const hasAsyncIteratorSymbol = tsutils .unionTypeParts(type) - .map(t => - tsutils.getWellKnownSymbolPropertyOfType( - t, - 'asyncIterator', - checker, - ), - ) - .find(symbol => symbol != null); - - if (asyncIteratorSymbol == null) { + .some( + typePart => + tsutils.getWellKnownSymbolPropertyOfType( + typePart, + 'asyncIterator', + checker, + ) != null, + ); + + if (!hasAsyncIteratorSymbol) { context.report({ loc: getForStatementHeadLoc(context.sourceCode, node), - messageId: 'forAwaitOfNonThenable', + messageId: 'forAwaitOfNonAsyncIterable', suggest: [ // Note that this suggestion causes broken code for sync iterables // of promises, since the loop variable is not awaited. @@ -112,6 +116,57 @@ export default createRule<[], MessageId>({ }); } }, + + 'VariableDeclaration[kind="await using"]'( + node: TSESTree.VariableDeclaration, + ): void { + for (const declarator of node.declarations) { + const init = declarator.init; + if (init == null) { + continue; + } + const type = services.getTypeAtLocation(init); + if (isTypeAnyType(type)) { + continue; + } + + const hasAsyncDisposeSymbol = tsutils + .unionTypeParts(type) + .some( + typePart => + tsutils.getWellKnownSymbolPropertyOfType( + typePart, + 'asyncDispose', + checker, + ) != null, + ); + + if (!hasAsyncDisposeSymbol) { + context.report({ + node: init, + messageId: 'awaitUsingOfNonAsyncDisposable', + // let the user figure out what to do if there's + // await using a = b, c = d, e = f; + // it's rare and not worth the complexity to handle. + ...getFixOrSuggest({ + fixOrSuggest: + node.declarations.length === 1 ? 'suggest' : 'none', + + suggestion: { + messageId: 'removeAwait', + fix(fixer): TSESLint.RuleFix { + const awaitToken = nullThrows( + context.sourceCode.getFirstToken(node, isAwaitKeyword), + NullThrowsReasons.MissingToken('await', 'await using'), + ); + return fixer.remove(awaitToken); + }, + }, + }), + }); + } + } + }, }; }, }); diff --git a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts index cd4894cc384c..e9487be62c23 100644 --- a/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts +++ b/packages/eslint-plugin/src/rules/prefer-optional-chain-utils/analyzeChain.ts @@ -413,11 +413,11 @@ function getReportDescriptor( }, messageId: 'preferOptionalChain', ...getFixOrSuggest({ + fixOrSuggest: useSuggestionFixer ? 'suggest' : 'fix', suggestion: { fix, messageId: 'optionalChainSuggest', }, - useFix: !useSuggestionFixer, }), }; diff --git a/packages/eslint-plugin/src/rules/return-await.ts b/packages/eslint-plugin/src/rules/return-await.ts index 08ad959e6c26..7795894a9584 100644 --- a/packages/eslint-plugin/src/rules/return-await.ts +++ b/packages/eslint-plugin/src/rules/return-await.ts @@ -6,6 +6,7 @@ import * as ts from 'typescript'; import { createRule, + getFixOrSuggest, getParserServices, isAwaitExpression, isAwaitKeyword, @@ -44,6 +45,7 @@ export default createRule({ requiresTypeChecking: true, }, fixable: 'code', + // eslint-disable-next-line eslint-plugin/require-meta-has-suggestions -- suggestions are exposed through a helper. hasSuggestions: true, messages: { disallowedPromiseAwait: @@ -340,14 +342,17 @@ export default createRule({ context.report({ node, messageId: 'requiredPromiseAwait', - ...fixOrSuggest(useAutoFix, { - messageId: 'requiredPromiseAwaitSuggestion', - fix: fixer => - insertAwait( - fixer, - node, - isHigherPrecedenceThanAwait(expression), - ), + ...getFixOrSuggest({ + fixOrSuggest: useAutoFix ? 'fix' : 'suggest', + suggestion: { + messageId: 'requiredPromiseAwaitSuggestion', + fix: fixer => + insertAwait( + fixer, + node, + isHigherPrecedenceThanAwait(expression), + ), + }, }), }); } @@ -359,9 +364,12 @@ export default createRule({ context.report({ node, messageId: 'disallowedPromiseAwait', - ...fixOrSuggest(useAutoFix, { - messageId: 'disallowedPromiseAwaitSuggestion', - fix: fixer => removeAwait(fixer, node), + ...getFixOrSuggest({ + fixOrSuggest: useAutoFix ? 'fix' : 'suggest', + suggestion: { + messageId: 'disallowedPromiseAwaitSuggestion', + fix: fixer => removeAwait(fixer, node), + }, }), }); } @@ -446,12 +454,3 @@ function getConfiguration(option: Option): RuleConfiguration { }; } } - -function fixOrSuggest( - useFix: boolean, - suggestion: TSESLint.SuggestionReportDescriptor, -): - | { fix: TSESLint.ReportFixFunction } - | { suggest: TSESLint.SuggestionReportDescriptor[] } { - return useFix ? { fix: suggestion.fix } : { suggest: [suggestion] }; -} diff --git a/packages/eslint-plugin/src/util/getFixOrSuggest.ts b/packages/eslint-plugin/src/util/getFixOrSuggest.ts index 50ad7d2779a1..017d50c26454 100644 --- a/packages/eslint-plugin/src/util/getFixOrSuggest.ts +++ b/packages/eslint-plugin/src/util/getFixOrSuggest.ts @@ -1,13 +1,21 @@ import type { TSESLint } from '@typescript-eslint/utils'; export function getFixOrSuggest({ + fixOrSuggest, suggestion, - useFix, }: { + fixOrSuggest: 'fix' | 'none' | 'suggest'; suggestion: TSESLint.SuggestionReportDescriptor; - useFix: boolean; }): | { fix: TSESLint.ReportFixFunction } - | { suggest: TSESLint.SuggestionReportDescriptor[] } { - return useFix ? { fix: suggestion.fix } : { suggest: [suggestion] }; + | { suggest: TSESLint.SuggestionReportDescriptor[] } + | undefined { + switch (fixOrSuggest) { + case 'fix': + return { fix: suggestion.fix }; + case 'none': + return undefined; + case 'suggest': + return { suggest: [suggestion] }; + } } diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/await-thenable.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/await-thenable.shot index 176f8e64da8e..3d760f63873e 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/await-thenable.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/await-thenable.shot @@ -81,3 +81,50 @@ async function validUseOfForAwaitOnAsyncIterable() { } " `; + +exports[`Validating rule docs await-thenable.mdx code examples ESLint output 5`] = ` +"Incorrect + +function makeSyncDisposable(): Disposable { + return { + [Symbol.dispose](): void { + // Dispose of the resource + }, + }; +} + +async function shouldNotAwait() { + await using resource = makeSyncDisposable(); + ~~~~~~~~~~~~~~~~~~~~ Unexpected \`await using\` of a value that is not async disposable. +} +" +`; + +exports[`Validating rule docs await-thenable.mdx code examples ESLint output 6`] = ` +"Correct + +function makeSyncDisposable(): Disposable { + return { + [Symbol.dispose](): void { + // Dispose of the resource + }, + }; +} + +async function shouldNotAwait() { + using resource = makeSyncDisposable(); +} + +function makeAsyncDisposable(): AsyncDisposable { + return { + async [Symbol.asyncDispose](): Promise { + // Dispose of the resource asynchronously + }, + }; +} + +async function shouldAwait() { + await using resource = makeAsyncDisposable(); +} +" +`; diff --git a/packages/eslint-plugin/tests/docs.test.ts b/packages/eslint-plugin/tests/docs.test.ts index a0f1d74dad30..f2add45161cb 100644 --- a/packages/eslint-plugin/tests/docs.test.ts +++ b/packages/eslint-plugin/tests/docs.test.ts @@ -252,9 +252,10 @@ describe('Validating rule docs', () => { // Get all H2 headings objects as the other levels are variable by design. const headings = tokens.filter(tokenIsH2); - headings.forEach(heading => - expect(heading.text).toBe(titleCase(heading.text)), - ); + headings.forEach(heading => { + const nonCodeText = heading.text.replace(/`[^`]*`/g, ''); + expect(nonCodeText).toBe(titleCase(nonCodeText)); + }); }); const headings = tokens.filter(tokenIsHeading); diff --git a/packages/eslint-plugin/tests/rules/await-thenable.test.ts b/packages/eslint-plugin/tests/rules/await-thenable.test.ts index c91542819bb8..0739334cf130 100644 --- a/packages/eslint-plugin/tests/rules/await-thenable.test.ts +++ b/packages/eslint-plugin/tests/rules/await-thenable.test.ts @@ -224,6 +224,57 @@ async function forAwait() { code: ` declare const asyncIter: AsyncIterable | Iterable; for await (const s of asyncIter) { +} + `, + }, + { + code: ` +declare const d: AsyncDisposable; + +await using foo = d; + +export {}; + `, + }, + { + code: ` +using foo = { + [Symbol.dispose]() {}, +}; + +export {}; + `, + }, + { + code: ` +await using foo = 3 as any; + +export {}; + `, + }, + { + // bad bad code but not this rule's problem + code: ` +using foo = { + async [Symbol.dispose]() {}, +}; + +export {}; + `, + }, + { + code: ` +declare const maybeAsyncDisposable: Disposable | AsyncDisposable; +async function foo() { + await using _ = maybeAsyncDisposable; +} + `, + }, + { + code: ` +async function iterateUsing(arr: Array) { + for (await using foo of arr) { + } } `, }, @@ -424,7 +475,7 @@ for await (const value of yieldNumbers()) { endColumn: 42, endLine: 7, line: 7, - messageId: 'forAwaitOfNonThenable', + messageId: 'forAwaitOfNonAsyncIterable', suggestions: [ { messageId: 'convertToOrdinaryFor', @@ -456,7 +507,7 @@ for await (const value of yieldNumberPromises()) { `, errors: [ { - messageId: 'forAwaitOfNonThenable', + messageId: 'forAwaitOfNonAsyncIterable', suggestions: [ { messageId: 'convertToOrdinaryFor', @@ -475,5 +526,118 @@ for (const value of yieldNumberPromises()) { }, ], }, + { + code: ` +declare const disposable: Disposable; +async function foo() { + await using d = disposable; +} + `, + errors: [ + { + column: 19, + endColumn: 29, + endLine: 4, + line: 4, + messageId: 'awaitUsingOfNonAsyncDisposable', + suggestions: [ + { + messageId: 'removeAwait', + output: ` +declare const disposable: Disposable; +async function foo() { + using d = disposable; +} + `, + }, + ], + }, + ], + }, + { + code: ` +async function foo() { + await using _ = { + async [Symbol.dispose]() {}, + }; +} + `, + errors: [ + { + column: 19, + endColumn: 4, + endLine: 5, + line: 3, + messageId: 'awaitUsingOfNonAsyncDisposable', + suggestions: [ + { + messageId: 'removeAwait', + output: ` +async function foo() { + using _ = { + async [Symbol.dispose]() {}, + }; +} + `, + }, + ], + }, + ], + }, + { + code: ` +declare const disposable: Disposable; +declare const asyncDisposable: AsyncDisposable; +async function foo() { + await using a = disposable, + b = asyncDisposable, + c = disposable, + d = asyncDisposable, + e = disposable; +} + `, + errors: [ + { + column: 19, + endColumn: 29, + endLine: 5, + line: 5, + messageId: 'awaitUsingOfNonAsyncDisposable', + }, + { + column: 9, + endColumn: 19, + endLine: 7, + line: 7, + messageId: 'awaitUsingOfNonAsyncDisposable', + }, + { + column: 9, + endColumn: 19, + endLine: 9, + line: 9, + messageId: 'awaitUsingOfNonAsyncDisposable', + }, + ], + }, + { + code: ` +declare const anee: any; +declare const disposable: Disposable; +async function foo() { + await using a = anee, + b = disposable; +} + `, + errors: [ + { + column: 9, + endColumn: 19, + endLine: 6, + line: 6, + messageId: 'awaitUsingOfNonAsyncDisposable', + }, + ], + }, ], });