From 806ac110c122c3c9cae8986b667cd99d205b16d5 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sat, 26 Oct 2024 12:00:55 -0600 Subject: [PATCH 1/5] [await-thenable] report unnecessary await using statements --- .../docs/rules/await-thenable.mdx | 58 ++++++- .../eslint-plugin/src/rules/await-thenable.ts | 83 +++++++++-- .../analyzeChain.ts | 2 +- .../eslint-plugin/src/rules/return-await.ts | 38 +++-- .../eslint-plugin/src/util/getFixOrSuggest.ts | 16 +- .../await-thenable.shot | 47 ++++++ packages/eslint-plugin/tests/docs.test.ts | 14 +- .../tests/rules/await-thenable.test.ts | 141 +++++++++++++++++- 8 files changed, 354 insertions(+), 45 deletions(-) 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..19ffff63b0b9 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 = nullThrows( + declarator.init, + 'Expected init to be present on an await using variable declarator', + ); + const type = services.getTypeAtLocation(init); + if (isTypeAnyType(type)) { + return; + } + + 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..48be7419cea6 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, @@ -340,14 +341,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 +363,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 +453,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..5c9608107342 100644 --- a/packages/eslint-plugin/tests/docs.test.ts +++ b/packages/eslint-plugin/tests/docs.test.ts @@ -252,9 +252,17 @@ 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 => { + // Hard-code exceptions for code in headings. + if ( + heading.text === + 'Explicit Resource Management (`await using` Statements)' + ) { + return; + } + + expect(heading.text).toBe(titleCase(heading.text)); + }); }); 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..cc06f1eb686f 100644 --- a/packages/eslint-plugin/tests/rules/await-thenable.test.ts +++ b/packages/eslint-plugin/tests/rules/await-thenable.test.ts @@ -224,6 +224,49 @@ 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; } `, }, @@ -424,7 +467,7 @@ for await (const value of yieldNumbers()) { endColumn: 42, endLine: 7, line: 7, - messageId: 'forAwaitOfNonThenable', + messageId: 'forAwaitOfNonAsyncIterable', suggestions: [ { messageId: 'convertToOrdinaryFor', @@ -456,7 +499,7 @@ for await (const value of yieldNumberPromises()) { `, errors: [ { - messageId: 'forAwaitOfNonThenable', + messageId: 'forAwaitOfNonAsyncIterable', suggestions: [ { messageId: 'convertToOrdinaryFor', @@ -475,5 +518,99 @@ 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', + }, + ], + }, ], }); From 660f810b2d4c6e0ea7e2115ab47dd3ad1c9be160 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sat, 26 Oct 2024 13:00:14 -0600 Subject: [PATCH 2/5] continue, not return --- .../eslint-plugin/src/rules/await-thenable.ts | 2 +- .../tests/rules/await-thenable.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/await-thenable.ts b/packages/eslint-plugin/src/rules/await-thenable.ts index 19ffff63b0b9..7522227f671f 100644 --- a/packages/eslint-plugin/src/rules/await-thenable.ts +++ b/packages/eslint-plugin/src/rules/await-thenable.ts @@ -127,7 +127,7 @@ export default createRule<[], MessageId>({ ); const type = services.getTypeAtLocation(init); if (isTypeAnyType(type)) { - return; + continue; } const hasAsyncDisposeSymbol = tsutils diff --git a/packages/eslint-plugin/tests/rules/await-thenable.test.ts b/packages/eslint-plugin/tests/rules/await-thenable.test.ts index cc06f1eb686f..cc1156e83d15 100644 --- a/packages/eslint-plugin/tests/rules/await-thenable.test.ts +++ b/packages/eslint-plugin/tests/rules/await-thenable.test.ts @@ -612,5 +612,24 @@ async function foo() { }, ], }, + { + 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', + }, + ], + }, ], }); From ea8339a5945855696a1cd1ec0eb2190f6c0ce70f Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sun, 27 Oct 2024 01:06:42 -0600 Subject: [PATCH 3/5] await using without init --- packages/eslint-plugin/src/rules/await-thenable.ts | 8 ++++---- packages/eslint-plugin/tests/rules/await-thenable.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/src/rules/await-thenable.ts b/packages/eslint-plugin/src/rules/await-thenable.ts index 7522227f671f..c1b50766e3ae 100644 --- a/packages/eslint-plugin/src/rules/await-thenable.ts +++ b/packages/eslint-plugin/src/rules/await-thenable.ts @@ -121,10 +121,10 @@ export default createRule<[], MessageId>({ node: TSESTree.VariableDeclaration, ): void { for (const declarator of node.declarations) { - const init = nullThrows( - declarator.init, - 'Expected init to be present on an await using variable declarator', - ); + const init = declarator.init; + if (init == null) { + continue; + } const type = services.getTypeAtLocation(init); if (isTypeAnyType(type)) { continue; diff --git a/packages/eslint-plugin/tests/rules/await-thenable.test.ts b/packages/eslint-plugin/tests/rules/await-thenable.test.ts index cc1156e83d15..0739334cf130 100644 --- a/packages/eslint-plugin/tests/rules/await-thenable.test.ts +++ b/packages/eslint-plugin/tests/rules/await-thenable.test.ts @@ -267,6 +267,14 @@ export {}; declare const maybeAsyncDisposable: Disposable | AsyncDisposable; async function foo() { await using _ = maybeAsyncDisposable; +} + `, + }, + { + code: ` +async function iterateUsing(arr: Array) { + for (await using foo of arr) { + } } `, }, From 80f159c7ef36b38ec138ff26b2f5f02789771974 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Sun, 27 Oct 2024 11:38:08 -0600 Subject: [PATCH 4/5] disable lint error --- packages/eslint-plugin/src/rules/return-await.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin/src/rules/return-await.ts b/packages/eslint-plugin/src/rules/return-await.ts index 48be7419cea6..7795894a9584 100644 --- a/packages/eslint-plugin/src/rules/return-await.ts +++ b/packages/eslint-plugin/src/rules/return-await.ts @@ -45,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: From dc463aa01601247abb62a9cc89aa918401cf4806 Mon Sep 17 00:00:00 2001 From: Kirk Waiblinger Date: Mon, 4 Nov 2024 09:37:29 -0700 Subject: [PATCH 5/5] exempt code ticks from upper-case heading test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Josh Goldberg ✨ --- packages/eslint-plugin/tests/docs.test.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/eslint-plugin/tests/docs.test.ts b/packages/eslint-plugin/tests/docs.test.ts index 5c9608107342..f2add45161cb 100644 --- a/packages/eslint-plugin/tests/docs.test.ts +++ b/packages/eslint-plugin/tests/docs.test.ts @@ -253,15 +253,8 @@ describe('Validating rule docs', () => { const headings = tokens.filter(tokenIsH2); headings.forEach(heading => { - // Hard-code exceptions for code in headings. - if ( - heading.text === - 'Explicit Resource Management (`await using` Statements)' - ) { - return; - } - - expect(heading.text).toBe(titleCase(heading.text)); + const nonCodeText = heading.text.replace(/`[^`]*`/g, ''); + expect(nonCodeText).toBe(titleCase(nonCodeText)); }); });