UNPKG

eslint-plugin-unicorn

Version:
397 lines (345 loc) • 15 kB
import {findVariable} from '@eslint-community/eslint-utils'; import {isCallExpression, isFunction} from './ast/index.js'; import { getIndentUnit, getLinebreak, getLineIndent, reindentText, } from './utils/index.js'; const MESSAGE_ID_ERROR = 'prefer-dispose/error'; const MESSAGE_ID_SUGGESTION = 'prefer-dispose/suggestion'; const messages = { [MESSAGE_ID_ERROR]: 'Prefer `{{keyword}}` (Explicit Resource Management) over the `try`/`finally` disposal pattern.', [MESSAGE_ID_SUGGESTION]: 'Replace the `try`/`finally` with `{{keyword}}`.', }; // Common resource cleanup method names. `using` itself calls `[Symbol.dispose]()`/`[Symbol.asyncDispose]()`. const DISPOSAL_METHOD_NAMES = new Set(['close', 'dispose', 'destroy', 'end']); // Parse one statement of a `finally` block into a disposal descriptor, or return `undefined` if it // isn't a simple `foo.close()` / `foo[Symbol.dispose]()` (optionally `await`-ed) call. function parseDisposalStatement(statement) { if (statement.type !== 'ExpressionStatement') { return; } let {expression} = statement; let isAwaited = false; if (expression.type === 'AwaitExpression') { isAwaited = true; expression = expression.argument; } // Unwrap the `ChainExpression` around optional chaining, for example `foo?.close()`. if (expression.type === 'ChainExpression') { expression = expression.expression; } if (!isCallExpression(expression, {argumentsLength: 0})) { return; } const {callee} = expression; if ( callee.type !== 'MemberExpression' || callee.object.type !== 'Identifier' ) { return; } const {property} = callee; if (callee.computed) { // `foo[Symbol.dispose]()` / `foo[Symbol.asyncDispose]()` if ( property.type !== 'MemberExpression' || property.computed || property.object.type !== 'Identifier' || property.object.name !== 'Symbol' || property.property.type !== 'Identifier' || (property.property.name !== 'dispose' && property.property.name !== 'asyncDispose') ) { return; } if (property.property.name === 'asyncDispose') { isAwaited = true; } } else if ( property.type !== 'Identifier' || !DISPOSAL_METHOD_NAMES.has(property.name) ) { return; } return {resourceName: callee.object.name, isAwaited}; } // `await using` is only legal inside an async function or at module top-level. // The walk always ends at the `Program` at the latest. function isInAsyncContext(node) { let current = node.parent; while (true) { if (isFunction(current)) { return current.async === true; } // `await` is not allowed in a class static block. if (current.type === 'StaticBlock') { return false; } if (current.type === 'Program') { return current.sourceType === 'module'; } current = current.parent; } } // With type information, confirm the resources actually implement `Symbol.dispose`/`Symbol.asyncDispose`. // Without it, `using` would throw a `TypeError` at runtime, so this avoids suggesting broken code. // `getProperties()` resolves a union to its common properties and an intersection to its merged // properties, so a union member that is not disposable correctly prevents the suggestion. // // Resolving and inspecting types can crash deep inside TypeScript 6 while it computes module specifiers // for symbols declared in other modules (`Cannot read properties of undefined (reading 'includes')`). // We cannot then confirm disposability, so we conservatively report it as non-disposable rather than crash the lint run. function areAllResourcesDisposable(resources, parserServices) { try { return resources.every(({identifier, isAwaited}) => { const type = parserServices.getTypeAtLocation(identifier); return type.getProperties().some(symbol => { // TypeScript stores well-known symbol members under the escaped name `__@<name>@<id>` // (for example `__@dispose@9`). We match that directly instead of `typeChecker.symbolToString()`, // which is one of the calls that crashes. const name = String(symbol.escapedName); return name.startsWith('__@dispose') || (isAwaited && name.startsWith('__@asyncDispose')); }); }); // Defensive: no known input crashes the TypeScript version this is tested with. /* node:coverage ignore next 3 */ } catch { return false; } } // Resolve a disposed name to the `const`/`let` declaration that can become a `using` declaration. function resolveResource(disposal, scope, body) { const variable = findVariable(scope, disposal.resourceName); if (!variable || variable.defs.length !== 1) { return; } const [definition] = variable.defs; if ( definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator' || !definition.node.init || definition.node.id.type !== 'Identifier' ) { return; } // `definition.kind` is populated by espree but is undefined under `@typescript-eslint/parser`, // so fall back to `definition.parent.kind` (the enclosing `VariableDeclaration`). See `prefer-top-level-await`. const declaration = definition.parent; const kind = definition.kind ?? declaration.kind; if ( (kind !== 'const' && kind !== 'let') || declaration.declarations.length !== 1 || declaration.parent.type === 'ExportNamedDeclaration' || !body.includes(declaration) ) { return; } return { ...disposal, variable, declaration, identifier: definition.node.id, }; } // Whether the resolved resources can be safely converted to `using` declarations. function areResourcesConvertible(resources, tryStatement, sourceCode, parserServices) { const {body} = tryStatement.parent; // `resources` arrives in the `finally` disposal order. `using` disposes in reverse declaration // order (LIFO), so only convert when the `finally` already disposes in that order; otherwise the // suggestion would change the observable cleanup order and could break dependent resources. const disposalOrder = resources.map(({declaration}) => declaration); // All declarations must sit contiguously, immediately before the `try`. resources.sort((a, b) => body.indexOf(a.declaration) - body.indexOf(b.declaration)); const expectedStartIndex = body.indexOf(tryStatement) - resources.length; if ( expectedStartIndex < 0 || resources.some(({declaration}, index) => body[expectedStartIndex + index] !== declaration) || disposalOrder.some((declaration, index) => declaration !== resources.at(-1 - index).declaration) ) { return false; } // The resource must not be reassigned or used after the `try`, since `using` is block-scoped. const [, tryStatementEnd] = sourceCode.getRange(tryStatement); const isReassignedOrUsedAfterTry = ({references}) => references.some(reference => (!reference.init && reference.isWrite()) || sourceCode.getRange(reference.identifier)[0] > tryStatementEnd); if (resources.some(({variable}) => isReassignedOrUsedAfterTry(variable))) { return false; } if (resources.some(({isAwaited}) => isAwaited) && !isInAsyncContext(tryStatement)) { return false; } // When type information is available, only report resources that are actually disposable. return !(parserServices?.program && !areAllResourcesDisposable(resources, parserServices)); } // Match a `try`/`finally` resource-disposal pattern and return the resources to convert, or `undefined`. function findResources(tryStatement, sourceCode, parserServices) { const {finalizer} = tryStatement; if (!finalizer) { return; } // The `finally` block must consist solely of disposal calls. const disposals = []; for (const statement of finalizer.body) { const disposal = parseDisposalStatement(statement); if (!disposal) { return; } disposals.push(disposal); } if (disposals.length === 0) { return; } const names = new Set(disposals.map(({resourceName}) => resourceName)); if (names.size !== disposals.length) { return; } const {parent} = tryStatement; if (parent.type !== 'BlockStatement' && parent.type !== 'Program') { return; } const scope = sourceCode.getScope(tryStatement); const resources = []; for (const disposal of disposals) { const resource = resolveResource(disposal, scope, parent.body); if (!resource) { return; } resources.push(resource); } if (areResourcesConvertible(resources, tryStatement, sourceCode, parserServices)) { return resources; } } // Without a `catch`, the fix unwraps the `try` block into the new `using` block, merging the two // scopes. A binding declared in the `try` block that shares a resource name would then collide with // the `using` declaration, so the suggestion must be skipped. function hasShadowingConflict(tryStatement, resources, sourceCode) { if (tryStatement.handler) { return false; } // A block statement always has its own scope, since `const`/`let` declarations need ES2015 const blockScope = sourceCode.getScope(tryStatement.block); const resourceNames = new Set(resources.map(({resourceName}) => resourceName)); return blockScope.variables.some(({name}) => resourceNames.has(name)); } function * fixTryStatement(fixer, tryStatement, resources, context) { const {sourceCode} = context; const linebreak = getLinebreak(context); // The new block wraps the `try`, so its closing brace lines up with the `try` statement const blockIndent = getLineIndent(tryStatement, context); // Turn each declaration into `using`/`await using`, opening a new block before the first one. // The declarations end up inside the new block, so they move one indentation level in. Only the first one starts a new line, so it also carries that line's indentation itself. const indentUnit = getIndentUnit(context); for (const [index, {declaration, isAwaited}] of resources.entries()) { const keywordToken = sourceCode.getFirstToken(declaration); const keyword = isAwaited ? 'await using' : 'using'; const text = index === 0 ? `{${linebreak}${getLineIndent(declaration, context)}${indentUnit}${keyword}` : `${indentUnit}${keyword}`; yield fixer.replaceText(keywordToken, text); } // Everything the fix keeps moves one level in with the declarations, so the kept text is re-indented, which also drops the lines that end up holding nothing but whitespace. const bodyIndent = `${blockIndent}${indentUnit}`; const [tryStart, tryStatementEnd] = sourceCode.getRange(tryStatement); // The replacement starts at the beginning of the `try` line, so the indentation that line carries is part of what is rewritten. It belongs to a line whose content has just moved. Only when the `try` is the first thing on its line, otherwise that line has other content. const lineStart = sourceCode.getIndexFromLoc({ line: sourceCode.getLoc(tryStatement).start.line, column: 0, }); const replaceStart = sourceCode.text.slice(lineStart, tryStart).trim() === '' ? lineStart : tryStart; // Re-indenting would change the content of a multi-line template literal, the text is kept as it is then const hasMultilineTemplate = sourceCode.getTokens(tryStatement).some(token => { const {start, end} = sourceCode.getLoc(token); return token.type === 'Template' && start.line !== end.line; }); const reindent = (text, sourceIndent, targetIndent) => hasMultilineTemplate ? text : reindentText(text, sourceIndent, targetIndent); if (tryStatement.handler) { // Keep `try { … } catch (…) { … }`, drop only the `finally`, and close the new block. const [, handlerEnd] = sourceCode.getRange(tryStatement.handler); // `reindentText` leaves the first line alone, so a line break is put in front to make the `try` line one of the lines it moves, and taken off again afterwards. const kept = reindent( `${linebreak}${sourceCode.text.slice(replaceStart, handlerEnd)}`, blockIndent, bodyIndent, ).slice(linebreak.length).trimEnd(); yield fixer.replaceTextRange([replaceStart, tryStatementEnd], `${kept}${linebreak}${blockIndent}}`); return; } // No `catch`: unwrap the `try` block into the new block. const tryBlockOpen = sourceCode.getFirstToken(tryStatement.block); const tryBlockClose = sourceCode.getLastToken(tryStatement.block); const [, tryBlockOpenEnd] = sourceCode.getRange(tryBlockOpen); const [tryBlockCloseStart] = sourceCode.getRange(tryBlockClose); /* Unwrapping the `try` block and wrapping the declarations in a new one cancel out, so the body keeps its indentation. Passing the same indent on both sides only drops the lines that are left with nothing but whitespace. The leading line breaks go, the first line's indentation stays. */ const rawBody = reindent( sourceCode.text.slice(tryBlockOpenEnd, tryBlockCloseStart), bodyIndent, bodyIndent, ); // A body that starts on its own line is already indented for the new block, so only the line breaks go. One that trails the `{` on the same line takes the new block's indentation. const body = /^[\n\r\u{2028}\u{2029}]/u.test(rawBody) ? rawBody.replace(/^(?:\r\n|[\n\r\u{2028}\u{2029}])+/u, '').trimEnd() : `${bodyIndent}${rawBody.replace(/^[^\S\n\r\u{2028}\u{2029}]+/u, '').trimEnd()}`; yield fixer.replaceTextRange( [replaceStart, tryStatementEnd], body.trim() ? `${linebreak}${body}${linebreak}${blockIndent}}` : `${linebreak}${blockIndent}}`, ); } /** @param {import('eslint').Rule.RuleContext} context */ const create = context => { const {sourceCode} = context; const {parserServices} = sourceCode; context.on('TryStatement', tryStatement => { const resources = findResources(tryStatement, sourceCode, parserServices); if (!resources) { return; } const data = {keyword: resources.some(({isAwaited}) => isAwaited) ? 'await using' : 'using'}; const problem = {node: tryStatement, messageId: MESSAGE_ID_ERROR, data}; // Skip the suggestion when applying the fix would drop comments. The `try` block and `catch` // handler are kept verbatim, so their comments survive; the `finally` block and anything // between the structural keywords are removed, so comments there would be lost. Declarations // keep their comments because the fix only replaces their leading keyword token. const preservedComments = new Set([ ...sourceCode.getCommentsInside(tryStatement.block), ...(tryStatement.handler ? sourceCode.getCommentsInside(tryStatement.handler) : []), ]); if ( sourceCode.getCommentsInside(tryStatement).some(comment => !preservedComments.has(comment)) || hasShadowingConflict(tryStatement, resources, sourceCode) ) { return problem; } problem.suggest = [ { messageId: MESSAGE_ID_SUGGESTION, data, fix: fixer => fixTryStatement(fixer, tryStatement, resources, context), }, ]; return problem; }); }; /** @type {import('eslint').Rule.RuleModule} */ const config = { create, meta: { type: 'suggestion', docs: { description: 'Prefer using `using`/`await using` over manual `try`/`finally` resource disposal.', // Enable in the `recommended` config once we target Node.js 24, where `using`/`await using` is supported natively. recommended: false, }, hasSuggestions: true, messages, languages: [ 'js/js', ], }, }; export default config;