diff --git a/packages/tools/eslint-custom-rules/package.json b/packages/tools/eslint-custom-rules/package.json index eec933b561..8c33b3edc0 100644 --- a/packages/tools/eslint-custom-rules/package.json +++ b/packages/tools/eslint-custom-rules/package.json @@ -13,7 +13,10 @@ "dependencies": { "@typescript-eslint/experimental-utils": "^5.14.0", "@typescript-eslint/parser": "^5.14.0", + "@typescript-eslint/scope-manager": "5.14.0", + "@typescript-eslint/type-utils": "5.14.0", "@typescript-eslint/types": "^5.14.0", + "@typescript-eslint/utils": "5.14.0", "lodash": "^4.17.21", "tsutils": "^3.21.0" } diff --git a/packages/tools/eslint-custom-rules/src/index.ts b/packages/tools/eslint-custom-rules/src/index.ts index 63a4180e58..8a9f9e3f2b 100644 --- a/packages/tools/eslint-custom-rules/src/index.ts +++ b/packages/tools/eslint-custom-rules/src/index.ts @@ -1,7 +1,7 @@ import { preferReadonlyParameterTypesRule, preferReadonlyParameterTypesRuleName, -} from './rules/prefer-readonly-parameter-types'; +} from './rules/prefer-readonly-parameter-types-old'; const thisPluginName = 'noshiro-custom'; diff --git a/packages/tools/eslint-custom-rules/src/rules/prefer-readonly-parameter-types-old.ts b/packages/tools/eslint-custom-rules/src/rules/prefer-readonly-parameter-types-old.ts new file mode 100644 index 0000000000..48383f30d6 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/rules/prefer-readonly-parameter-types-old.ts @@ -0,0 +1,152 @@ +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; +import type { TSESTree } from '@typescript-eslint/types'; +import { AST_NODE_TYPES } from '@typescript-eslint/types'; +import type { __String } from 'typescript'; +import { isTypeReadonly } from '../utils/isTypeReadonly'; + +type Options = readonly [ + Readonly<{ + checkParameterProperties?: boolean; + ignoreInferredTypes?: boolean; + allow?: readonly __String[]; + }> +]; + +type MessageIds = 'shouldBeReadonly'; + +const createRule = ESLintUtils.RuleCreator( + () => + `https://github.com/typescript-eslint/typescript-eslint/blob/v4.24.0/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md` +); + +export const preferReadonlyParameterTypesRuleName = + 'prefer-readonly-parameter-types'; + +export const preferReadonlyParameterTypesRule = createRule( + { + name: preferReadonlyParameterTypesRuleName, + meta: { + type: 'suggestion', + docs: { + description: + 'Requires that function parameters are typed as readonly to prevent accidental mutation of inputs', + category: 'Possible Errors', + recommended: false, + requiresTypeChecking: true, + }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + checkParameterProperties: { + type: 'boolean', + }, + ignoreInferredTypes: { + type: 'boolean', + }, + allow: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + ], + messages: { + shouldBeReadonly: 'Parameter should be a read only type.', + }, + }, + defaultOptions: [ + { + checkParameterProperties: true, + ignoreInferredTypes: false, + allow: [], + }, + ], + create(context, options) { + const [{ checkParameterProperties, ignoreInferredTypes, allow }] = + options; + const { esTreeNodeToTSNodeMap, program } = + ESLintUtils.getParserServices(context); + const checker = program.getTypeChecker(); + + return { + [[ + AST_NODE_TYPES.ArrowFunctionExpression, + AST_NODE_TYPES.FunctionDeclaration, + AST_NODE_TYPES.FunctionExpression, + AST_NODE_TYPES.TSCallSignatureDeclaration, + AST_NODE_TYPES.TSConstructSignatureDeclaration, + AST_NODE_TYPES.TSDeclareFunction, + AST_NODE_TYPES.TSEmptyBodyFunctionExpression, + AST_NODE_TYPES.TSFunctionType, + AST_NODE_TYPES.TSMethodSignature, + ].join(', ')]( + node: + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.TSCallSignatureDeclaration + | TSESTree.TSConstructSignatureDeclaration + | TSESTree.TSDeclareFunction + | TSESTree.TSEmptyBodyFunctionExpression + | TSESTree.TSFunctionType + | TSESTree.TSMethodSignature + ): void { + for (const param of node.params) { + if ( + checkParameterProperties === false && + param.type === AST_NODE_TYPES.TSParameterProperty + ) { + continue; + } + + const actualParam = + param.type === AST_NODE_TYPES.TSParameterProperty + ? param.parameter + : param; + + if ( + ignoreInferredTypes === true && + actualParam.typeAnnotation == null + ) { + continue; + } + + const tsNode = esTreeNodeToTSNodeMap.get(actualParam); + const type = checker.getTypeAtLocation(tsNode); + const isReadOnly = isTypeReadonly(checker, type); + + if (isReadOnly) { + continue; + } + + if (allow !== undefined) { + if ( + type.symbol !== undefined && + allow.includes(type.symbol.escapedName) + ) { + continue; + } + if ( + type.aliasSymbol !== undefined && + allow.includes(type.aliasSymbol.escapedName) + ) { + continue; + } + } + + context.report({ + node: actualParam, + messageId: 'shouldBeReadonly', + }); + } + }, + }; + }, + } +); + +export default preferReadonlyParameterTypesRule; diff --git a/packages/tools/eslint-custom-rules/src/rules/prefer-readonly-parameter-types.ts b/packages/tools/eslint-custom-rules/src/rules/prefer-readonly-parameter-types.ts index 48383f30d6..5bd188d977 100644 --- a/packages/tools/eslint-custom-rules/src/rules/prefer-readonly-parameter-types.ts +++ b/packages/tools/eslint-custom-rules/src/rules/prefer-readonly-parameter-types.ts @@ -1,152 +1,111 @@ -import { ESLintUtils } from '@typescript-eslint/experimental-utils'; -import type { TSESTree } from '@typescript-eslint/types'; -import { AST_NODE_TYPES } from '@typescript-eslint/types'; -import type { __String } from 'typescript'; -import { isTypeReadonly } from '../utils/isTypeReadonly'; +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import * as util from '../util'; -type Options = readonly [ - Readonly<{ +type Options = [ + { checkParameterProperties?: boolean; ignoreInferredTypes?: boolean; - allow?: readonly __String[]; - }> + } & util.ReadonlynessOptions ]; - type MessageIds = 'shouldBeReadonly'; -const createRule = ESLintUtils.RuleCreator( - () => - `https://github.com/typescript-eslint/typescript-eslint/blob/v4.24.0/packages/eslint-plugin/docs/rules/prefer-readonly-parameter-types.md` -); - -export const preferReadonlyParameterTypesRuleName = - 'prefer-readonly-parameter-types'; - -export const preferReadonlyParameterTypesRule = createRule( - { - name: preferReadonlyParameterTypesRuleName, - meta: { - type: 'suggestion', - docs: { - description: - 'Requires that function parameters are typed as readonly to prevent accidental mutation of inputs', - category: 'Possible Errors', - recommended: false, - requiresTypeChecking: true, - }, - schema: [ - { - type: 'object', - additionalProperties: false, - properties: { - checkParameterProperties: { - type: 'boolean', - }, - ignoreInferredTypes: { - type: 'boolean', - }, - allow: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - }, - ], - messages: { - shouldBeReadonly: 'Parameter should be a read only type.', - }, +export default util.createRule({ + name: 'prefer-readonly-parameter-types', + meta: { + type: 'suggestion', + docs: { + description: + 'Requires that function parameters are typed as readonly to prevent accidental mutation of inputs', + recommended: false, + requiresTypeChecking: true, }, - defaultOptions: [ + schema: [ { - checkParameterProperties: true, - ignoreInferredTypes: false, - allow: [], + type: 'object', + additionalProperties: false, + properties: { + checkParameterProperties: { + type: 'boolean', + }, + ignoreInferredTypes: { + type: 'boolean', + }, + ...util.readonlynessOptionsSchema.properties, + }, }, ], - create(context, options) { - const [{ checkParameterProperties, ignoreInferredTypes, allow }] = - options; - const { esTreeNodeToTSNodeMap, program } = - ESLintUtils.getParserServices(context); - const checker = program.getTypeChecker(); - - return { - [[ - AST_NODE_TYPES.ArrowFunctionExpression, - AST_NODE_TYPES.FunctionDeclaration, - AST_NODE_TYPES.FunctionExpression, - AST_NODE_TYPES.TSCallSignatureDeclaration, - AST_NODE_TYPES.TSConstructSignatureDeclaration, - AST_NODE_TYPES.TSDeclareFunction, - AST_NODE_TYPES.TSEmptyBodyFunctionExpression, - AST_NODE_TYPES.TSFunctionType, - AST_NODE_TYPES.TSMethodSignature, - ].join(', ')]( - node: - | TSESTree.ArrowFunctionExpression - | TSESTree.FunctionDeclaration - | TSESTree.FunctionExpression - | TSESTree.TSCallSignatureDeclaration - | TSESTree.TSConstructSignatureDeclaration - | TSESTree.TSDeclareFunction - | TSESTree.TSEmptyBodyFunctionExpression - | TSESTree.TSFunctionType - | TSESTree.TSMethodSignature - ): void { - for (const param of node.params) { - if ( - checkParameterProperties === false && - param.type === AST_NODE_TYPES.TSParameterProperty - ) { - continue; - } - - const actualParam = - param.type === AST_NODE_TYPES.TSParameterProperty - ? param.parameter - : param; + messages: { + shouldBeReadonly: 'Parameter should be a read only type.', + }, + }, + defaultOptions: [ + { + checkParameterProperties: true, + ignoreInferredTypes: false, + ...util.readonlynessOptionsDefaults, + }, + ], + create( + context, + [{ checkParameterProperties, ignoreInferredTypes, treatMethodsAsReadonly }] + ) { + const { esTreeNodeToTSNodeMap, program } = util.getParserServices(context); + const checker = program.getTypeChecker(); - if ( - ignoreInferredTypes === true && - actualParam.typeAnnotation == null - ) { - continue; - } + return { + [[ + AST_NODE_TYPES.ArrowFunctionExpression, + AST_NODE_TYPES.FunctionDeclaration, + AST_NODE_TYPES.FunctionExpression, + AST_NODE_TYPES.TSCallSignatureDeclaration, + AST_NODE_TYPES.TSConstructSignatureDeclaration, + AST_NODE_TYPES.TSDeclareFunction, + AST_NODE_TYPES.TSEmptyBodyFunctionExpression, + AST_NODE_TYPES.TSFunctionType, + AST_NODE_TYPES.TSMethodSignature, + ].join(', ')]( + node: + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionDeclaration + | TSESTree.FunctionExpression + | TSESTree.TSCallSignatureDeclaration + | TSESTree.TSConstructSignatureDeclaration + | TSESTree.TSDeclareFunction + | TSESTree.TSEmptyBodyFunctionExpression + | TSESTree.TSFunctionType + | TSESTree.TSMethodSignature + ): void { + for (const param of node.params) { + if ( + !checkParameterProperties && + param.type === AST_NODE_TYPES.TSParameterProperty + ) { + continue; + } - const tsNode = esTreeNodeToTSNodeMap.get(actualParam); - const type = checker.getTypeAtLocation(tsNode); - const isReadOnly = isTypeReadonly(checker, type); + const actualParam = + param.type === AST_NODE_TYPES.TSParameterProperty + ? param.parameter + : param; - if (isReadOnly) { - continue; - } + if (ignoreInferredTypes && actualParam.typeAnnotation == null) { + continue; + } - if (allow !== undefined) { - if ( - type.symbol !== undefined && - allow.includes(type.symbol.escapedName) - ) { - continue; - } - if ( - type.aliasSymbol !== undefined && - allow.includes(type.aliasSymbol.escapedName) - ) { - continue; - } - } + const tsNode = esTreeNodeToTSNodeMap.get(actualParam); + const type = checker.getTypeAtLocation(tsNode); + const isReadOnly = util.isTypeReadonly(checker, type, { + treatMethodsAsReadonly: treatMethodsAsReadonly!, + }); + if (!isReadOnly) { context.report({ node: actualParam, messageId: 'shouldBeReadonly', }); } - }, - }; - }, - } -); - -export default preferReadonlyParameterTypesRule; + } + }, + }; + }, +}); diff --git a/packages/tools/eslint-custom-rules/src/type-utils/index.ts b/packages/tools/eslint-custom-rules/src/type-utils/index.ts new file mode 100644 index 0000000000..07410d220c --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/type-utils/index.ts @@ -0,0 +1,35 @@ +// this is done for convenience - saves migrating all of the old rules +export { + AnyType, + containsAllTypesByName, + getConstrainedTypeAtLocation, + getContextualType, + getDeclaration, + getSourceFileOfNode, + getTokenAtPosition, + getTypeArguments, + getTypeFlags, + getTypeName, + getTypeOfPropertyOfName, + getTypeOfPropertyOfType, + isAnyOrAnyArrayTypeDiscriminated, + isNullableType, + isTypeAnyArrayType, + isTypeAnyType, + isTypeArrayTypeOrUnionOfArrayTypes, + isTypeBigIntLiteralType, + isTypeFlagSet, + isTypeNeverType, + // isTypeReadonly, + isTypeReferenceType, + isTypeTemplateLiteralType, + isTypeUnknownArrayType, + isTypeUnknownType, + isUnsafeAssignment, + // ReadonlynessOptions, + // readonlynessOptionsDefaults, + // readonlynessOptionsSchema, + requiresQuoting, + typeIsOrHasBaseType, +} from '@typescript-eslint/type-utils'; +export * from './isTypeReadonly'; diff --git a/packages/tools/eslint-custom-rules/src/type-utils/isTypeReadonly.ts b/packages/tools/eslint-custom-rules/src/type-utils/isTypeReadonly.ts new file mode 100644 index 0000000000..0b340a3259 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/type-utils/isTypeReadonly.ts @@ -0,0 +1,325 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; +import { + isConditionalType, + isIntersectionType, + isObjectType, + isPropertyReadonlyInType, + isSymbolFlagSet, + isUnionType, + unionTypeParts, +} from 'tsutils'; +import * as ts from 'typescript'; +import { getTypeOfPropertyOfType } from './propertyTypes'; + +const enum Readonlyness { + /** the type cannot be handled by the function */ + UnknownType = 1, + /** the type is mutable */ + Mutable = 2, + /** the type is readonly */ + Readonly = 3, +} + +export interface ReadonlynessOptions { + readonly treatMethodsAsReadonly?: boolean; +} + +export const readonlynessOptionsSchema = { + type: 'object', + additionalProperties: false, + properties: { + treatMethodsAsReadonly: { + type: 'boolean', + }, + }, +}; + +export const readonlynessOptionsDefaults: ReadonlynessOptions = { + treatMethodsAsReadonly: false, +}; + +function hasSymbol(node: ts.Node): node is ts.Node & { symbol: ts.Symbol } { + return Object.prototype.hasOwnProperty.call(node, 'symbol'); +} + +function isTypeReadonlyArrayOrTuple( + checker: ts.TypeChecker, + type: ts.Type, + options: ReadonlynessOptions, + seenTypes: Set +): Readonlyness { + function checkTypeArguments(arrayType: ts.TypeReference): Readonlyness { + const typeArguments = + // getTypeArguments was only added in TS3.7 + checker.getTypeArguments + ? checker.getTypeArguments(arrayType) + : arrayType.typeArguments ?? []; + + // this shouldn't happen in reality as: + // - tuples require at least 1 type argument + // - ReadonlyArray requires at least 1 type argument + /* istanbul ignore if */ if (typeArguments.length === 0) { + return Readonlyness.Readonly; + } + + // validate the element types are also readonly + if ( + typeArguments.some( + (typeArg) => + isTypeReadonlyRecurser(checker, typeArg, options, seenTypes) === + Readonlyness.Mutable + ) + ) { + return Readonlyness.Mutable; + } + return Readonlyness.Readonly; + } + + if (checker.isArrayType(type)) { + const symbol = ESLintUtils.nullThrows( + type.getSymbol(), + ESLintUtils.NullThrowsReasons.MissingToken('symbol', 'array type') + ); + const escapedName = symbol.getEscapedName(); + if (escapedName === 'Array') { + return Readonlyness.Mutable; + } + + return checkTypeArguments(type); + } + + if (checker.isTupleType(type)) { + if (!type.target.readonly) { + return Readonlyness.Mutable; + } + + return checkTypeArguments(type); + } + + return Readonlyness.UnknownType; +} + +function isTypeReadonlyObject( + checker: ts.TypeChecker, + type: ts.Type, + options: ReadonlynessOptions, + seenTypes: Set +): Readonlyness { + function checkIndexSignature(kind: ts.IndexKind): Readonlyness { + const indexInfo = checker.getIndexInfoOfType(type, kind); + if (indexInfo) { + if (!indexInfo.isReadonly) { + return Readonlyness.Mutable; + } + + return isTypeReadonlyRecurser( + checker, + indexInfo.type, + options, + seenTypes + ); + } + + return Readonlyness.UnknownType; + } + + const properties = type.getProperties(); + if (properties.length) { + // ensure the properties are marked as readonly + for (const property of properties) { + if (options.treatMethodsAsReadonly) { + if ( + property.valueDeclaration !== undefined && + hasSymbol(property.valueDeclaration) && + isSymbolFlagSet( + property.valueDeclaration.symbol, + ts.SymbolFlags.Method + ) + ) { + continue; + } + + const declarations = property.getDeclarations(); + const lastDeclaration = + declarations !== undefined && declarations.length > 0 + ? declarations[declarations.length - 1] + : undefined; + if ( + lastDeclaration !== undefined && + hasSymbol(lastDeclaration) && + isSymbolFlagSet(lastDeclaration.symbol, ts.SymbolFlags.Method) + ) { + continue; + } + } + + if (isPropertyReadonlyInType(type, property.getEscapedName(), checker)) { + continue; + } + + const name = ts.getNameOfDeclaration(property.valueDeclaration); + if (name && ts.isPrivateIdentifier(name)) { + continue; + } + + return Readonlyness.Mutable; + } + + // all properties were readonly + // now ensure that all of the values are readonly also. + + // do this after checking property readonly-ness as a perf optimization, + // as we might be able to bail out early due to a mutable property before + // doing this deep, potentially expensive check. + for (const property of properties) { + const propertyType = ESLintUtils.nullThrows( + getTypeOfPropertyOfType(checker, type, property), + ESLintUtils.NullThrowsReasons.MissingToken( + `property "${property.name}"`, + 'type' + ) + ); + + // handle recursive types. + // we only need this simple check, because a mutable recursive type will break via the above prop readonly check + if (seenTypes.has(propertyType)) { + continue; + } + + if ( + isTypeReadonlyRecurser(checker, propertyType, options, seenTypes) === + Readonlyness.Mutable + ) { + return Readonlyness.Mutable; + } + } + } + + const isStringIndexSigReadonly = checkIndexSignature(ts.IndexKind.String); + if (isStringIndexSigReadonly === Readonlyness.Mutable) { + return isStringIndexSigReadonly; + } + + const isNumberIndexSigReadonly = checkIndexSignature(ts.IndexKind.Number); + if (isNumberIndexSigReadonly === Readonlyness.Mutable) { + return isNumberIndexSigReadonly; + } + + return Readonlyness.Readonly; +} + +// a helper function to ensure the seenTypes map is always passed down, except by the external caller +function isTypeReadonlyRecurser( + checker: ts.TypeChecker, + type: ts.Type, + options: ReadonlynessOptions, + seenTypes: Set +): Readonlyness.Readonly | Readonlyness.Mutable { + seenTypes.add(type); + + if (isUnionType(type)) { + // all types in the union must be readonly + const result = unionTypeParts(type).every( + (t) => + seenTypes.has(t) || + isTypeReadonlyRecurser(checker, t, options, seenTypes) === + Readonlyness.Readonly + ); + const readonlyness = result ? Readonlyness.Readonly : Readonlyness.Mutable; + return readonlyness; + } + + if (isIntersectionType(type)) { + // Special case for handling arrays/tuples (as readonly arrays/tuples always have mutable methods). + if ( + type.types.some((t) => checker.isArrayType(t) || checker.isTupleType(t)) + ) { + const allReadonlyParts = type.types.every( + (t) => + seenTypes.has(t) || + isTypeReadonlyRecurser(checker, t, options, seenTypes) === + Readonlyness.Readonly + ); + return allReadonlyParts ? Readonlyness.Readonly : Readonlyness.Mutable; + } + + // Normal case. + const isReadonlyObject = isTypeReadonlyObject( + checker, + type, + options, + seenTypes + ); + if (isReadonlyObject !== Readonlyness.UnknownType) { + return isReadonlyObject; + } + } + + if (isConditionalType(type)) { + const result = [type.root.node.trueType, type.root.node.falseType] + .map(checker.getTypeFromTypeNode) + .every( + (t) => + seenTypes.has(t) || + isTypeReadonlyRecurser(checker, t, options, seenTypes) === + Readonlyness.Readonly + ); + + const readonlyness = result ? Readonlyness.Readonly : Readonlyness.Mutable; + return readonlyness; + } + + // all non-object, non-intersection types are readonly. + // this should only be primitive types + if (!isObjectType(type)) { + return Readonlyness.Readonly; + } + + // pure function types are readonly + if ( + type.getCallSignatures().length > 0 && + type.getProperties().length === 0 + ) { + return Readonlyness.Readonly; + } + + const isReadonlyArray = isTypeReadonlyArrayOrTuple( + checker, + type, + options, + seenTypes + ); + if (isReadonlyArray !== Readonlyness.UnknownType) { + return isReadonlyArray; + } + + const isReadonlyObject = isTypeReadonlyObject( + checker, + type, + options, + seenTypes + ); + /* istanbul ignore else */ if ( + isReadonlyObject !== Readonlyness.UnknownType + ) { + return isReadonlyObject; + } + + throw new Error('Unhandled type'); +} + +/** + * Checks if the given type is readonly + */ +function isTypeReadonly( + checker: ts.TypeChecker, + type: ts.Type, + options: ReadonlynessOptions = readonlynessOptionsDefaults +): boolean { + return ( + isTypeReadonlyRecurser(checker, type, options, new Set()) === + Readonlyness.Readonly + ); +} + +export { isTypeReadonly }; diff --git a/packages/tools/eslint-custom-rules/src/type-utils/propertyTypes.ts b/packages/tools/eslint-custom-rules/src/type-utils/propertyTypes.ts new file mode 100644 index 0000000000..901c73000e --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/type-utils/propertyTypes.ts @@ -0,0 +1,53 @@ +import * as ts from 'typescript'; + +export function getTypeOfPropertyOfName( + checker: ts.TypeChecker, + type: ts.Type, + name: string, + escapedName?: ts.__String +): ts.Type | undefined { + // Most names are directly usable in the checker and aren't different from escaped names + if (!escapedName || !isSymbol(escapedName)) { + return checker.getTypeOfPropertyOfType(type, name); + } + + // Symbolic names may differ in their escaped name compared to their human-readable name + // https://github.com/typescript-eslint/typescript-eslint/issues/2143 + const escapedProperty = type + .getProperties() + .find((property) => property.escapedName === escapedName); + + return escapedProperty + ? checker.getDeclaredTypeOfSymbol(escapedProperty) + : undefined; +} + +export function getTypeOfPropertyOfType( + checker: ts.TypeChecker, + type: ts.Type, + property: ts.Symbol +): ts.Type | undefined { + return getTypeOfPropertyOfName( + checker, + type, + property.getName(), + property.getEscapedName() + ); +} + +// Symbolic names need to be specially handled because TS api is not sufficient for these cases. +// Source based on: +// https://github.com/microsoft/TypeScript/blob/0043abe982aae0d35f8df59f9715be6ada758ff7/src/compiler/utilities.ts#L3388-L3402 +function isSymbol(escapedName: string): boolean { + return isKnownSymbol(escapedName) || isPrivateIdentifierSymbol(escapedName); +} + +// case for escapedName: "__@foo@10", name: "__@foo@10" +function isKnownSymbol(escapedName: string): boolean { + return escapedName.startsWith('__@'); +} + +// case for escapedName: "__#1@#foo", name: "#foo" +function isPrivateIdentifierSymbol(escapedName: string): boolean { + return escapedName.startsWith('__#'); +} diff --git a/packages/tools/eslint-custom-rules/src/util/astUtils.ts b/packages/tools/eslint-custom-rules/src/util/astUtils.ts new file mode 100644 index 0000000000..dd502e0267 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/astUtils.ts @@ -0,0 +1,79 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import * as ts from 'typescript'; +import { escapeRegExp } from './escapeRegExp'; + +// deeply re-export, for convenience +export * from '@typescript-eslint/utils/dist/ast-utils'; + +// The following is copied from `eslint`'s source code since it doesn't exist in eslint@5. +// https://github.com/eslint/eslint/blob/145aec1ab9052fbca96a44d04927c595951b1536/lib/rules/utils/ast-utils.js#L1751-L1779 +// Could be export { getNameLocationInGlobalDirectiveComment } from 'eslint/lib/rules/utils/ast-utils' +/** + * Get the `loc` object of a given name in a `/*globals` directive comment. + * @param {SourceCode} sourceCode The source code to convert index to loc. + * @param {Comment} comment The `/*globals` directive comment which include the name. + * @param {string} name The name to find. + * @returns {SourceLocation} The `loc` object. + */ +export function getNameLocationInGlobalDirectiveComment( + sourceCode: TSESLint.SourceCode, + comment: TSESTree.Comment, + name: string +): TSESTree.SourceLocation { + const namePattern = new RegExp( + `[\\s,]${escapeRegExp(name)}(?:$|[\\s,:])`, + 'gu' + ); + + // To ignore the first text "global". + namePattern.lastIndex = comment.value.indexOf('global') + 6; + + // Search a given variable name. + const match = namePattern.exec(comment.value); + + // Convert the index to loc. + const start = sourceCode.getLocFromIndex( + comment.range[0] + '/*'.length + (match ? match.index + 1 : 0) + ); + const end = { + line: start.line, + column: start.column + (match ? name.length : 1), + }; + + return { start, end }; +} + +// Copied from typescript https://github.com/microsoft/TypeScript/blob/42b0e3c4630c129ca39ce0df9fff5f0d1b4dd348/src/compiler/utilities.ts#L1335 +// Warning: This has the same semantics as the forEach family of functions, +// in that traversal terminates in the event that 'visitor' supplies a truthy value. +export function forEachReturnStatement( + body: ts.Block, + visitor: (stmt: ts.ReturnStatement) => T +): T | undefined { + return traverse(body); + + function traverse(node: ts.Node): T | undefined { + switch (node.kind) { + case ts.SyntaxKind.ReturnStatement: + return visitor(node); + case ts.SyntaxKind.CaseBlock: + case ts.SyntaxKind.Block: + case ts.SyntaxKind.IfStatement: + case ts.SyntaxKind.DoStatement: + case ts.SyntaxKind.WhileStatement: + case ts.SyntaxKind.ForStatement: + case ts.SyntaxKind.ForInStatement: + case ts.SyntaxKind.ForOfStatement: + case ts.SyntaxKind.WithStatement: + case ts.SyntaxKind.SwitchStatement: + case ts.SyntaxKind.CaseClause: + case ts.SyntaxKind.DefaultClause: + case ts.SyntaxKind.LabeledStatement: + case ts.SyntaxKind.TryStatement: + case ts.SyntaxKind.CatchClause: + return ts.forEachChild(node, traverse); + } + + return undefined; + } +} diff --git a/packages/tools/eslint-custom-rules/src/util/collectUnusedVariables.ts b/packages/tools/eslint-custom-rules/src/util/collectUnusedVariables.ts new file mode 100644 index 0000000000..4dca844170 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/collectUnusedVariables.ts @@ -0,0 +1,758 @@ +import { ImplicitLibVariable } from '@typescript-eslint/scope-manager'; +import { Visitor } from '@typescript-eslint/scope-manager/dist/referencer/Visitor'; +import { + ASTUtils, + AST_NODE_TYPES, + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +class UnusedVarsVisitor< + TMessageIds extends string, + TOptions extends readonly unknown[] +> extends Visitor { + private static readonly RESULTS_CACHE = new WeakMap< + TSESTree.Program, + ReadonlySet + >(); + + readonly #scopeManager: TSESLint.Scope.ScopeManager; + // readonly #unusedVariables = new Set(); + + private constructor(context: TSESLint.RuleContext) { + super({ + visitChildrenEvenIfSelectorExists: true, + }); + + this.#scopeManager = ESLintUtils.nullThrows( + context.getSourceCode().scopeManager, + 'Missing required scope manager' + ); + } + + public static collectUnusedVariables< + TMessageIds extends string, + TOptions extends readonly unknown[] + >( + context: TSESLint.RuleContext + ): ReadonlySet { + const program = context.getSourceCode().ast; + const cached = this.RESULTS_CACHE.get(program); + if (cached) { + return cached; + } + + const visitor = new this(context); + visitor.visit(program); + + const unusedVars = visitor.collectUnusedVariables( + visitor.getScope(program) + ); + this.RESULTS_CACHE.set(program, unusedVars); + return unusedVars; + } + + private collectUnusedVariables( + scope: TSESLint.Scope.Scope, + unusedVariables = new Set() + ): ReadonlySet { + for (const variable of scope.variables) { + if ( + // skip function expression names, + scope.functionExpressionScope || + // variables marked with markVariableAsUsed(), + variable.eslintUsed || + // implicit lib variables (from @typescript-eslint/scope-manager), + variable instanceof ImplicitLibVariable || + // basic exported variables + isExported(variable) || + // variables implicitly exported via a merged declaration + isMergableExported(variable) || + // used variables + isUsedVariable(variable) + ) { + continue; + } + + unusedVariables.add(variable); + } + + for (const childScope of scope.childScopes) { + this.collectUnusedVariables(childScope, unusedVariables); + } + + return unusedVariables; + } + + //#region HELPERS + + private getScope( + currentNode: TSESTree.Node + ): T { + // On Program node, get the outermost scope to avoid return Node.js special function scope or ES modules scope. + const inner = currentNode.type !== AST_NODE_TYPES.Program; + + let node: TSESTree.Node | undefined = currentNode; + while (node) { + const scope = this.#scopeManager.acquire(node, inner); + + if (scope) { + if (scope.type === 'function-expression-name') { + return scope.childScopes[0] as T; + } + return scope as T; + } + + node = node.parent; + } + + return this.#scopeManager.scopes[0] as T; + } + + private markVariableAsUsed( + variableOrIdentifier: TSESLint.Scope.Variable | TSESTree.Identifier + ): void; + private markVariableAsUsed(name: string, parent: TSESTree.Node): void; + private markVariableAsUsed( + variableOrIdentifierOrName: + | TSESLint.Scope.Variable + | TSESTree.Identifier + | string, + parent?: TSESTree.Node + ): void { + if ( + typeof variableOrIdentifierOrName !== 'string' && + !('type' in variableOrIdentifierOrName) + ) { + variableOrIdentifierOrName.eslintUsed = true; + return; + } + + let name: string; + let node: TSESTree.Node; + if (typeof variableOrIdentifierOrName === 'string') { + name = variableOrIdentifierOrName; + node = parent!; + } else { + name = variableOrIdentifierOrName.name; + node = variableOrIdentifierOrName; + } + + let currentScope: TSESLint.Scope.Scope | null = this.getScope(node); + while (currentScope) { + const variable = currentScope.variables.find( + (scopeVar) => scopeVar.name === name + ); + + if (variable) { + variable.eslintUsed = true; + return; + } + + currentScope = currentScope.upper; + } + } + + private visitClass( + node: TSESTree.ClassDeclaration | TSESTree.ClassExpression + ): void { + // skip a variable of class itself name in the class scope + const scope = this.getScope(node); + for (const variable of scope.variables) { + if (variable.identifiers[0] === scope.block.id) { + this.markVariableAsUsed(variable); + return; + } + } + } + + private visitFunction( + node: TSESTree.FunctionDeclaration | TSESTree.FunctionExpression + ): void { + const scope = this.getScope(node); + // skip implicit "arguments" variable + const variable = scope.set.get('arguments'); + if (variable?.defs.length === 0) { + this.markVariableAsUsed(variable); + } + } + + private visitFunctionTypeSignature( + node: + | TSESTree.TSCallSignatureDeclaration + | TSESTree.TSConstructorType + | TSESTree.TSConstructSignatureDeclaration + | TSESTree.TSDeclareFunction + | TSESTree.TSEmptyBodyFunctionExpression + | TSESTree.TSFunctionType + | TSESTree.TSMethodSignature + ): void { + // function type signature params create variables because they can be referenced within the signature, + // but they obviously aren't unused variables for the purposes of this rule. + for (const param of node.params) { + this.visitPattern(param, (name) => { + this.markVariableAsUsed(name); + }); + } + } + + private visitSetter( + node: TSESTree.MethodDefinition | TSESTree.Property + ): void { + if (node.kind === 'set') { + // ignore setter parameters because they're syntactically required to exist + for (const param of (node.value as TSESTree.FunctionLike).params) { + this.visitPattern(param, (id) => { + this.markVariableAsUsed(id); + }); + } + } + } + + //#endregion HELPERS + + //#region VISITORS + // NOTE - This is a simple visitor - meaning it does not support selectors + + protected ClassDeclaration = this.visitClass; + + protected ClassExpression = this.visitClass; + + protected FunctionDeclaration = this.visitFunction; + + protected FunctionExpression = this.visitFunction; + + protected ForInStatement(node: TSESTree.ForInStatement): void { + /** + * (Brad Zacher): I hate that this has to exist. + * But it is required for compat with the base ESLint rule. + * + * In 2015, ESLint decided to add an exception for these two specific cases + * ``` + * for (var key in object) return; + * + * var key; + * for (key in object) return; + * ``` + * + * I disagree with it, but what are you going to do... + * + * https://github.com/eslint/eslint/issues/2342 + */ + + let idOrVariable; + if (node.left.type === AST_NODE_TYPES.VariableDeclaration) { + const variable = this.#scopeManager.getDeclaredVariables(node.left)[0]; + if (!variable) { + return; + } + idOrVariable = variable; + } + if (node.left.type === AST_NODE_TYPES.Identifier) { + idOrVariable = node.left; + } + + if (idOrVariable == null) { + return; + } + + let body = node.body; + if (node.body.type === AST_NODE_TYPES.BlockStatement) { + if (node.body.body.length !== 1) { + return; + } + body = node.body.body[0]; + } + + if (body.type !== AST_NODE_TYPES.ReturnStatement) { + return; + } + + this.markVariableAsUsed(idOrVariable); + } + + protected Identifier(node: TSESTree.Identifier): void { + const scope = this.getScope(node); + if ( + scope.type === TSESLint.Scope.ScopeType.function && + node.name === 'this' + ) { + // this parameters should always be considered used as they're pseudo-parameters + if ('params' in scope.block && scope.block.params.includes(node)) { + this.markVariableAsUsed(node); + } + } + } + + protected MethodDefinition = this.visitSetter; + + protected Property = this.visitSetter; + + protected TSCallSignatureDeclaration = this.visitFunctionTypeSignature; + + protected TSConstructorType = this.visitFunctionTypeSignature; + + protected TSConstructSignatureDeclaration = this.visitFunctionTypeSignature; + + protected TSDeclareFunction = this.visitFunctionTypeSignature; + + protected TSEmptyBodyFunctionExpression = this.visitFunctionTypeSignature; + + protected TSEnumDeclaration(node: TSESTree.TSEnumDeclaration): void { + // enum members create variables because they can be referenced within the enum, + // but they obviously aren't unused variables for the purposes of this rule. + const scope = this.getScope(node); + for (const variable of scope.variables) { + this.markVariableAsUsed(variable); + } + } + + protected TSFunctionType = this.visitFunctionTypeSignature; + + protected TSMappedType(node: TSESTree.TSMappedType): void { + // mapped types create a variable for their type name, but it's not necessary to reference it, + // so we shouldn't consider it as unused for the purpose of this rule. + this.markVariableAsUsed(node.typeParameter.name); + } + + protected TSMethodSignature = this.visitFunctionTypeSignature; + + protected TSModuleDeclaration(node: TSESTree.TSModuleDeclaration): void { + // -- global augmentation can be in any file, and they do not need exports + if (node.global === true) { + this.markVariableAsUsed('global', node.parent!); + } + } + + protected TSParameterProperty(node: TSESTree.TSParameterProperty): void { + let identifier: TSESTree.Identifier | null = null; + switch (node.parameter.type) { + case AST_NODE_TYPES.AssignmentPattern: + if (node.parameter.left.type === AST_NODE_TYPES.Identifier) { + identifier = node.parameter.left; + } + break; + + case AST_NODE_TYPES.Identifier: + identifier = node.parameter; + break; + } + + if (identifier) { + this.markVariableAsUsed(identifier); + } + } + + //#endregion VISITORS +} + +//#region private helpers + +/** + * Checks the position of given nodes. + * @param inner A node which is expected as inside. + * @param outer A node which is expected as outside. + * @returns `true` if the `inner` node exists in the `outer` node. + */ +function isInside(inner: TSESTree.Node, outer: TSESTree.Node): boolean { + return inner.range[0] >= outer.range[0] && inner.range[1] <= outer.range[1]; +} + +/** + * Determine if an identifier is referencing an enclosing name. + * This only applies to declarations that create their own scope (modules, functions, classes) + * @param ref The reference to check. + * @param nodes The candidate function nodes. + * @returns True if it's a self-reference, false if not. + */ +function isSelfReference( + ref: TSESLint.Scope.Reference, + nodes: Set +): boolean { + let scope: TSESLint.Scope.Scope | null = ref.from; + + while (scope) { + if (nodes.has(scope.block)) { + return true; + } + + scope = scope.upper; + } + + return false; +} + +const MERGABLE_TYPES = new Set([ + AST_NODE_TYPES.TSInterfaceDeclaration, + AST_NODE_TYPES.TSTypeAliasDeclaration, + AST_NODE_TYPES.TSModuleDeclaration, + AST_NODE_TYPES.ClassDeclaration, + AST_NODE_TYPES.FunctionDeclaration, +]); +/** + * Determine if the variable is directly exported + * @param variable the variable to check + * @param target the type of node that is expected to be exported + */ +function isMergableExported(variable: TSESLint.Scope.Variable): boolean { + // If all of the merged things are of the same type, TS will error if not all of them are exported - so we only need to find one + for (const def of variable.defs) { + // parameters can never be exported. + // their `node` prop points to the function decl, which can be exported + // so we need to special case them + if (def.type === TSESLint.Scope.DefinitionType.Parameter) { + continue; + } + + if ( + (MERGABLE_TYPES.has(def.node.type) && + def.node.parent?.type === AST_NODE_TYPES.ExportNamedDeclaration) || + def.node.parent?.type === AST_NODE_TYPES.ExportDefaultDeclaration + ) { + return true; + } + } + + return false; +} + +/** + * Determines if a given variable is being exported from a module. + * @param variable eslint-scope variable object. + * @returns True if the variable is exported, false if not. + */ +function isExported(variable: TSESLint.Scope.Variable): boolean { + const definition = variable.defs[0]; + + if (definition) { + let node = definition.node; + + if (node.type === AST_NODE_TYPES.VariableDeclarator) { + node = node.parent!; + } else if (definition.type === TSESLint.Scope.DefinitionType.Parameter) { + return false; + } + + return node.parent!.type.indexOf('Export') === 0; + } + return false; +} + +/** + * Determines if the variable is used. + * @param variable The variable to check. + * @returns True if the variable is used + */ +function isUsedVariable(variable: TSESLint.Scope.Variable): boolean { + /** + * Gets a list of function definitions for a specified variable. + * @param variable eslint-scope variable object. + * @returns Function nodes. + */ + function getFunctionDefinitions( + variable: TSESLint.Scope.Variable + ): Set { + const functionDefinitions = new Set(); + + variable.defs.forEach((def) => { + // FunctionDeclarations + if (def.type === TSESLint.Scope.DefinitionType.FunctionName) { + functionDefinitions.add(def.node); + } + + // FunctionExpressions + if ( + def.type === TSESLint.Scope.DefinitionType.Variable && + (def.node.init?.type === AST_NODE_TYPES.FunctionExpression || + def.node.init?.type === AST_NODE_TYPES.ArrowFunctionExpression) + ) { + functionDefinitions.add(def.node.init); + } + }); + return functionDefinitions; + } + + function getTypeDeclarations( + variable: TSESLint.Scope.Variable + ): Set { + const nodes = new Set(); + + variable.defs.forEach((def) => { + if ( + def.node.type === AST_NODE_TYPES.TSInterfaceDeclaration || + def.node.type === AST_NODE_TYPES.TSTypeAliasDeclaration + ) { + nodes.add(def.node); + } + }); + + return nodes; + } + + function getModuleDeclarations( + variable: TSESLint.Scope.Variable + ): Set { + const nodes = new Set(); + + variable.defs.forEach((def) => { + if (def.node.type === AST_NODE_TYPES.TSModuleDeclaration) { + nodes.add(def.node); + } + }); + + return nodes; + } + + /** + * Checks if the ref is contained within one of the given nodes + */ + function isInsideOneOf( + ref: TSESLint.Scope.Reference, + nodes: Set + ): boolean { + for (const node of nodes) { + if (isInside(ref.identifier, node)) { + return true; + } + } + + return false; + } + + /** + * If a given reference is left-hand side of an assignment, this gets + * the right-hand side node of the assignment. + * + * In the following cases, this returns null. + * + * - The reference is not the LHS of an assignment expression. + * - The reference is inside of a loop. + * - The reference is inside of a function scope which is different from + * the declaration. + * @param ref A reference to check. + * @param prevRhsNode The previous RHS node. + * @returns The RHS node or null. + */ + function getRhsNode( + ref: TSESLint.Scope.Reference, + prevRhsNode: TSESTree.Node | null + ): TSESTree.Node | null { + /** + * Checks whether the given node is in a loop or not. + * @param node The node to check. + * @returns `true` if the node is in a loop. + */ + function isInLoop(node: TSESTree.Node): boolean { + let currentNode: TSESTree.Node | undefined = node; + while (currentNode) { + if (ASTUtils.isFunction(currentNode)) { + break; + } + + if (ASTUtils.isLoop(currentNode)) { + return true; + } + + currentNode = currentNode.parent; + } + + return false; + } + + const id = ref.identifier; + const parent = id.parent!; + const grandparent = parent.parent!; + const refScope = ref.from.variableScope; + const varScope = ref.resolved!.scope.variableScope; + const canBeUsedLater = refScope !== varScope || isInLoop(id); + + /* + * Inherits the previous node if this reference is in the node. + * This is for `a = a + a`-like code. + */ + if (prevRhsNode && isInside(id, prevRhsNode)) { + return prevRhsNode; + } + + if ( + parent.type === AST_NODE_TYPES.AssignmentExpression && + grandparent.type === AST_NODE_TYPES.ExpressionStatement && + id === parent.left && + !canBeUsedLater + ) { + return parent.right; + } + return null; + } + + /** + * Checks whether a given reference is a read to update itself or not. + * @param ref A reference to check. + * @param rhsNode The RHS node of the previous assignment. + * @returns The reference is a read to update itself. + */ + function isReadForItself( + ref: TSESLint.Scope.Reference, + rhsNode: TSESTree.Node | null + ): boolean { + /** + * Checks whether a given Identifier node exists inside of a function node which can be used later. + * + * "can be used later" means: + * - the function is assigned to a variable. + * - the function is bound to a property and the object can be used later. + * - the function is bound as an argument of a function call. + * + * If a reference exists in a function which can be used later, the reference is read when the function is called. + * @param id An Identifier node to check. + * @param rhsNode The RHS node of the previous assignment. + * @returns `true` if the `id` node exists inside of a function node which can be used later. + */ + function isInsideOfStorableFunction( + id: TSESTree.Node, + rhsNode: TSESTree.Node + ): boolean { + /** + * Finds a function node from ancestors of a node. + * @param node A start node to find. + * @returns A found function node. + */ + function getUpperFunction(node: TSESTree.Node): TSESTree.Node | null { + let currentNode: TSESTree.Node | undefined = node; + while (currentNode) { + if (ASTUtils.isFunction(currentNode)) { + return currentNode; + } + currentNode = currentNode.parent; + } + + return null; + } + + /** + * Checks whether a given function node is stored to somewhere or not. + * If the function node is stored, the function can be used later. + * @param funcNode A function node to check. + * @param rhsNode The RHS node of the previous assignment. + * @returns `true` if under the following conditions: + * - the funcNode is assigned to a variable. + * - the funcNode is bound as an argument of a function call. + * - the function is bound to a property and the object satisfies above conditions. + */ + function isStorableFunction( + funcNode: TSESTree.Node, + rhsNode: TSESTree.Node + ): boolean { + let node = funcNode; + let parent = funcNode.parent; + + while (parent && isInside(parent, rhsNode)) { + switch (parent.type) { + case AST_NODE_TYPES.SequenceExpression: + if (parent.expressions[parent.expressions.length - 1] !== node) { + return false; + } + break; + + case AST_NODE_TYPES.CallExpression: + case AST_NODE_TYPES.NewExpression: + return parent.callee !== node; + + case AST_NODE_TYPES.AssignmentExpression: + case AST_NODE_TYPES.TaggedTemplateExpression: + case AST_NODE_TYPES.YieldExpression: + return true; + + default: + if ( + parent.type.endsWith('Statement') || + parent.type.endsWith('Declaration') + ) { + /* + * If it encountered statements, this is a complex pattern. + * Since analyzing complex patterns is hard, this returns `true` to avoid false positive. + */ + return true; + } + } + + node = parent; + parent = parent.parent; + } + + return false; + } + + const funcNode = getUpperFunction(id); + + return ( + !!funcNode && + isInside(funcNode, rhsNode) && + isStorableFunction(funcNode, rhsNode) + ); + } + + const id = ref.identifier; + const parent = id.parent!; + const grandparent = parent.parent!; + + return ( + ref.isRead() && // in RHS of an assignment for itself. e.g. `a = a + 1` + // self update. e.g. `a += 1`, `a++` + ((parent.type === AST_NODE_TYPES.AssignmentExpression && + grandparent.type === AST_NODE_TYPES.ExpressionStatement && + parent.left === id) || + (parent.type === AST_NODE_TYPES.UpdateExpression && + grandparent.type === AST_NODE_TYPES.ExpressionStatement) || + (!!rhsNode && + isInside(id, rhsNode) && + !isInsideOfStorableFunction(id, rhsNode))) + ); + } + + const functionNodes = getFunctionDefinitions(variable); + const isFunctionDefinition = functionNodes.size > 0; + + const typeDeclNodes = getTypeDeclarations(variable); + const isTypeDecl = typeDeclNodes.size > 0; + + const moduleDeclNodes = getModuleDeclarations(variable); + const isModuleDecl = moduleDeclNodes.size > 0; + + let rhsNode: TSESTree.Node | null = null; + + return variable.references.some((ref) => { + const forItself = isReadForItself(ref, rhsNode); + + rhsNode = getRhsNode(ref, rhsNode); + + return ( + ref.isRead() && + !forItself && + !(isFunctionDefinition && isSelfReference(ref, functionNodes)) && + !(isTypeDecl && isInsideOneOf(ref, typeDeclNodes)) && + !(isModuleDecl && isSelfReference(ref, moduleDeclNodes)) + ); + }); +} + +//#endregion private helpers + +/** + * Collects the set of unused variables for a given context. + * + * Due to complexity, this does not take into consideration: + * - variables within declaration files + * - variables within ambient module declarations + */ +function collectUnusedVariables< + TMessageIds extends string, + TOptions extends readonly unknown[] +>( + context: Readonly> +): ReadonlySet { + return UnusedVarsVisitor.collectUnusedVariables(context); +} + +export { collectUnusedVariables }; diff --git a/packages/tools/eslint-custom-rules/src/util/createRule.ts b/packages/tools/eslint-custom-rules/src/util/createRule.ts new file mode 100644 index 0000000000..819521d1bd --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/createRule.ts @@ -0,0 +1,5 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +export const createRule = ESLintUtils.RuleCreator( + (name) => `https://typescript-eslint.io/rules/${name}` +); diff --git a/packages/tools/eslint-custom-rules/src/util/escapeRegExp.ts b/packages/tools/eslint-custom-rules/src/util/escapeRegExp.ts new file mode 100644 index 0000000000..52d161b3b2 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/escapeRegExp.ts @@ -0,0 +1,12 @@ +/** + * Lodash + * Released under MIT license + */ +const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; +const reHasRegExpChar = RegExp(reRegExpChar.source); + +export function escapeRegExp(string = ''): string { + return string && reHasRegExpChar.test(string) + ? string.replace(reRegExpChar, '\\$&') + : string; +} diff --git a/packages/tools/eslint-custom-rules/src/util/explicitReturnTypeUtils.ts b/packages/tools/eslint-custom-rules/src/util/explicitReturnTypeUtils.ts new file mode 100644 index 0000000000..1492da221e --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/explicitReturnTypeUtils.ts @@ -0,0 +1,349 @@ +import { + AST_NODE_TYPES, + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; +import { isConstructor, isSetter, isTypeAssertion } from './astUtils'; +import { getFunctionHeadLoc } from './getFunctionHeadLoc'; + +type FunctionExpression = + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionExpression; +type FunctionNode = FunctionExpression | TSESTree.FunctionDeclaration; + +/** + * Checks if a node is a variable declarator with a type annotation. + * ``` + * const x: Foo = ... + * ``` + */ +function isVariableDeclaratorWithTypeAnnotation( + node: TSESTree.Node +): node is TSESTree.VariableDeclarator { + return ( + node.type === AST_NODE_TYPES.VariableDeclarator && !!node.id.typeAnnotation + ); +} + +/** + * Checks if a node is a class property with a type annotation. + * ``` + * public x: Foo = ... + * ``` + */ +function isPropertyDefinitionWithTypeAnnotation( + node: TSESTree.Node +): node is TSESTree.PropertyDefinition { + return ( + node.type === AST_NODE_TYPES.PropertyDefinition && !!node.typeAnnotation + ); +} + +/** + * Checks if a node belongs to: + * ``` + * new Foo(() => {}) + * ^^^^^^^^ + * ``` + */ +function isConstructorArgument( + node: TSESTree.Node +): node is TSESTree.NewExpression { + return node.type === AST_NODE_TYPES.NewExpression; +} + +/** + * Checks if a node is a property or a nested property of a typed object: + * ``` + * const x: Foo = { prop: () => {} } + * const x = { prop: () => {} } as Foo + * const x = { prop: () => {} } + * const x: Foo = { bar: { prop: () => {} } } + * ``` + */ +function isPropertyOfObjectWithType( + property: TSESTree.Node | undefined +): boolean { + if (!property || property.type !== AST_NODE_TYPES.Property) { + return false; + } + const objectExpr = property.parent; // this shouldn't happen, checking just in case + /* istanbul ignore if */ if ( + !objectExpr || + objectExpr.type !== AST_NODE_TYPES.ObjectExpression + ) { + return false; + } + + const parent = objectExpr.parent; // this shouldn't happen, checking just in case + /* istanbul ignore if */ if (!parent) { + return false; + } + + return ( + isTypeAssertion(parent) || + isPropertyDefinitionWithTypeAnnotation(parent) || + isVariableDeclaratorWithTypeAnnotation(parent) || + isFunctionArgument(parent) || + isPropertyOfObjectWithType(parent) + ); +} + +/** + * Checks if a function belongs to: + * ``` + * () => () => ... + * () => function () { ... } + * () => { return () => ... } + * () => { return function () { ... } } + * function fn() { return () => ... } + * function fn() { return function() { ... } } + * ``` + */ +function doesImmediatelyReturnFunctionExpression({ + body, +}: FunctionNode): boolean { + // Should always have a body; really checking just in case + /* istanbul ignore if */ if (!body) { + return false; + } + + // Check if body is a block with a single statement + if (body.type === AST_NODE_TYPES.BlockStatement && body.body.length === 1) { + const [statement] = body.body; + + // Check if that statement is a return statement with an argument + if ( + statement.type === AST_NODE_TYPES.ReturnStatement && + !!statement.argument + ) { + // If so, check that returned argument as body + body = statement.argument; + } + } + + // Check if the body being returned is a function expression + return ( + body.type === AST_NODE_TYPES.ArrowFunctionExpression || + body.type === AST_NODE_TYPES.FunctionExpression + ); +} + +/** + * Checks if a node belongs to: + * ``` + * foo(() => 1) + * ``` + */ +function isFunctionArgument( + parent: TSESTree.Node, + callee?: FunctionExpression +): parent is TSESTree.CallExpression { + return ( + parent.type === AST_NODE_TYPES.CallExpression && + // make sure this isn't an IIFE + parent.callee !== callee + ); +} + +/** + * Checks if a function belongs to: + * ``` + * () => ({ action: 'xxx' } as const) + * ``` + */ +function returnsConstAssertionDirectly( + node: TSESTree.ArrowFunctionExpression +): boolean { + const { body } = node; + if (isTypeAssertion(body)) { + const { typeAnnotation } = body; + if (typeAnnotation.type === AST_NODE_TYPES.TSTypeReference) { + const { typeName } = typeAnnotation; + if ( + typeName.type === AST_NODE_TYPES.Identifier && + typeName.name === 'const' + ) { + return true; + } + } + } + + return false; +} + +interface Options { + allowExpressions?: boolean; + allowTypedFunctionExpressions?: boolean; + allowHigherOrderFunctions?: boolean; + allowDirectConstAssertionInArrowFunctions?: boolean; +} + +/** + * True when the provided function expression is typed. + */ +function isTypedFunctionExpression( + node: FunctionExpression, + options: Options +): boolean { + const parent = ESLintUtils.nullThrows( + node.parent, + ESLintUtils.NullThrowsReasons.MissingParent + ); + + if (!options.allowTypedFunctionExpressions) { + return false; + } + + return ( + isTypeAssertion(parent) || + isVariableDeclaratorWithTypeAnnotation(parent) || + isPropertyDefinitionWithTypeAnnotation(parent) || + isPropertyOfObjectWithType(parent) || + isFunctionArgument(parent, node) || + isConstructorArgument(parent) + ); +} + +/** + * Check whether the function expression return type is either typed or valid + * with the provided options. + */ +function isValidFunctionExpressionReturnType( + node: FunctionExpression, + options: Options +): boolean { + if (isTypedFunctionExpression(node, options)) { + return true; + } + + const parent = ESLintUtils.nullThrows( + node.parent, + ESLintUtils.NullThrowsReasons.MissingParent + ); + if ( + options.allowExpressions && + parent.type !== AST_NODE_TYPES.VariableDeclarator && + parent.type !== AST_NODE_TYPES.MethodDefinition && + parent.type !== AST_NODE_TYPES.ExportDefaultDeclaration && + parent.type !== AST_NODE_TYPES.PropertyDefinition + ) { + return true; + } + + // https://github.com/typescript-eslint/typescript-eslint/issues/653 + return ( + options.allowDirectConstAssertionInArrowFunctions === true && + node.type === AST_NODE_TYPES.ArrowFunctionExpression && + returnsConstAssertionDirectly(node) + ); +} + +/** + * Check that the function expression or declaration is valid. + */ +function isValidFunctionReturnType( + node: FunctionNode, + options: Options +): boolean { + if ( + options.allowHigherOrderFunctions && + doesImmediatelyReturnFunctionExpression(node) + ) { + return true; + } + + return ( + node.returnType != null || + isConstructor(node.parent) || + isSetter(node.parent) + ); +} + +/** + * Checks if a function declaration/expression has a return type. + */ +function checkFunctionReturnType( + node: FunctionNode, + options: Options, + sourceCode: TSESLint.SourceCode, + report: (loc: TSESTree.SourceLocation) => void +): void { + if (isValidFunctionReturnType(node, options)) { + return; + } + + report(getFunctionHeadLoc(node, sourceCode)); +} + +/** + * Checks if a function declaration/expression has a return type. + */ +function checkFunctionExpressionReturnType( + node: FunctionExpression, + options: Options, + sourceCode: TSESLint.SourceCode, + report: (loc: TSESTree.SourceLocation) => void +): void { + if (isValidFunctionExpressionReturnType(node, options)) { + return; + } + + checkFunctionReturnType(node, options, sourceCode, report); +} + +/** + * Check whether any ancestor of the provided function has a valid return type. + */ +function ancestorHasReturnType(node: FunctionNode): boolean { + let ancestor = node.parent; + + if (ancestor?.type === AST_NODE_TYPES.Property) { + ancestor = ancestor.value; + } + + // if the ancestor is not a return, then this function was not returned at all, so we can exit early + const isReturnStatement = ancestor?.type === AST_NODE_TYPES.ReturnStatement; + const isBodylessArrow = + ancestor?.type === AST_NODE_TYPES.ArrowFunctionExpression && + ancestor.body.type !== AST_NODE_TYPES.BlockStatement; + if (!isReturnStatement && !isBodylessArrow) { + return false; + } + + while (ancestor) { + switch (ancestor.type) { + case AST_NODE_TYPES.ArrowFunctionExpression: + case AST_NODE_TYPES.FunctionExpression: + case AST_NODE_TYPES.FunctionDeclaration: + if (ancestor.returnType) { + return true; + } + break; + + // const x: Foo = () => {}; + // Assume that a typed variable types the function expression + case AST_NODE_TYPES.VariableDeclarator: + if (ancestor.id.typeAnnotation) { + return true; + } + break; + } + + ancestor = ancestor.parent; + } + + return false; +} + +export { + checkFunctionExpressionReturnType, + checkFunctionReturnType, + doesImmediatelyReturnFunctionExpression, + FunctionExpression, + FunctionNode, + isTypedFunctionExpression, + isValidFunctionExpressionReturnType, + ancestorHasReturnType, +}; diff --git a/packages/tools/eslint-custom-rules/src/util/getESLintCoreRule.ts b/packages/tools/eslint-custom-rules/src/util/getESLintCoreRule.ts new file mode 100644 index 0000000000..36fcac39cc --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/getESLintCoreRule.ts @@ -0,0 +1,64 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; +import { version } from 'eslint/package.json'; +import * as semver from 'semver'; + +const isESLintV8 = semver.major(version) >= 8; + +interface RuleMap { + 'arrow-parens': typeof import('eslint/lib/rules/arrow-parens'); + 'brace-style': typeof import('eslint/lib/rules/brace-style'); + 'comma-dangle': typeof import('eslint/lib/rules/comma-dangle'); + 'dot-notation': typeof import('eslint/lib/rules/dot-notation'); + indent: typeof import('eslint/lib/rules/indent'); + 'init-declarations': typeof import('eslint/lib/rules/init-declarations'); + 'keyword-spacing': typeof import('eslint/lib/rules/keyword-spacing'); + 'lines-between-class-members': typeof import('eslint/lib/rules/lines-between-class-members'); + 'no-dupe-args': typeof import('eslint/lib/rules/no-dupe-args'); + 'no-dupe-class-members': typeof import('eslint/lib/rules/no-dupe-class-members'); + 'no-duplicate-imports': typeof import('eslint/lib/rules/no-duplicate-imports'); + 'no-empty-function': typeof import('eslint/lib/rules/no-empty-function'); + 'no-extra-parens': typeof import('eslint/lib/rules/no-extra-parens'); + 'no-extra-semi': typeof import('eslint/lib/rules/no-extra-semi'); + 'no-implicit-globals': typeof import('eslint/lib/rules/no-implicit-globals'); + 'no-invalid-this': typeof import('eslint/lib/rules/no-invalid-this'); + 'no-loop-func': typeof import('eslint/lib/rules/no-loop-func'); + 'no-loss-of-precision': typeof import('eslint/lib/rules/no-loss-of-precision'); + 'no-magic-numbers': typeof import('eslint/lib/rules/no-magic-numbers'); + 'no-restricted-imports': typeof import('eslint/lib/rules/no-restricted-imports'); + 'no-undef': typeof import('eslint/lib/rules/no-undef'); + 'no-unused-expressions': typeof import('eslint/lib/rules/no-unused-expressions'); + 'no-useless-constructor': typeof import('eslint/lib/rules/no-useless-constructor'); + 'no-restricted-globals': typeof import('eslint/lib/rules/no-restricted-globals'); + 'object-curly-spacing': typeof import('eslint/lib/rules/object-curly-spacing'); + 'prefer-const': typeof import('eslint/lib/rules/prefer-const'); + quotes: typeof import('eslint/lib/rules/quotes'); + semi: typeof import('eslint/lib/rules/semi'); + 'space-before-blocks': typeof import('eslint/lib/rules/space-before-blocks'); + 'space-infix-ops': typeof import('eslint/lib/rules/space-infix-ops'); + strict: typeof import('eslint/lib/rules/strict'); +} + +type RuleId = keyof RuleMap; + +export const getESLintCoreRule: (ruleId: R) => RuleMap[R] = + isESLintV8 + ? (ruleId: R): RuleMap[R] => + ESLintUtils.nullThrows( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + require('eslint/use-at-your-own-risk').builtinRules.get( + ruleId + ) as RuleMap[R], + `ESLint's core rule '${ruleId}' not found.` + ) + : (ruleId: R): RuleMap[R] => + require(`eslint/lib/rules/${ruleId}`) as RuleMap[R]; + +export function maybeGetESLintCoreRule( + ruleId: R +): RuleMap[R] | null { + try { + return getESLintCoreRule(ruleId); + } catch { + return null; + } +} diff --git a/packages/tools/eslint-custom-rules/src/util/getFunctionHeadLoc.ts b/packages/tools/eslint-custom-rules/src/util/getFunctionHeadLoc.ts new file mode 100644 index 0000000000..3fc9445bb8 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/getFunctionHeadLoc.ts @@ -0,0 +1,79 @@ +import { + AST_NODE_TYPES, + AST_TOKEN_TYPES, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +type FunctionNode = + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionExpression + | TSESTree.FunctionDeclaration; + +/** + * Creates a report location for the given function. + * The location only encompasses the "start" of the function, and not the body + * + * eg. + * + * ``` + * function foo(args) {} + * ^^^^^^^^^^^^^^^^^^ + * + * get y(args) {} + * ^^^^^^^^^^^ + * + * const x = (args) => {} + * ^^^^^^^^^ + * ``` + */ +export function getFunctionHeadLoc( + node: FunctionNode, + sourceCode: TSESLint.SourceCode +): TSESTree.SourceLocation { + function getLocStart(): TSESTree.Position { + if (node.parent && node.parent.type === AST_NODE_TYPES.MethodDefinition) { + // return the start location for class method + + if (node.parent.decorators && node.parent.decorators.length > 0) { + // exclude decorators + return sourceCode.getTokenAfter( + node.parent.decorators[node.parent.decorators.length - 1] + )!.loc.start; + } + + return node.parent.loc.start; + } + + if ( + node.parent && + node.parent.type === AST_NODE_TYPES.Property && + node.parent.method + ) { + // return the start location for object method shorthand + return node.parent.loc.start; + } + + // return the start location for a regular function + return node.loc.start; + } + + function getLocEnd(): TSESTree.Position { + if (node.type === AST_NODE_TYPES.ArrowFunctionExpression) { + // find the end location for arrow function expression + return sourceCode.getTokenBefore( + node.body, + (token) => + token.type === AST_TOKEN_TYPES.Punctuator && token.value === '=>' + )!.loc.end; + } + + // return the end location for a regular function + return sourceCode.getTokenBefore(node.body)!.loc.end; + } + + return { + start: getLocStart(), + end: getLocEnd(), + }; +} diff --git a/packages/tools/eslint-custom-rules/src/util/getOperatorPrecedence.ts b/packages/tools/eslint-custom-rules/src/util/getOperatorPrecedence.ts new file mode 100644 index 0000000000..0ae82dd569 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/getOperatorPrecedence.ts @@ -0,0 +1,347 @@ +import { SyntaxKind } from 'typescript'; + +export enum OperatorPrecedence { + // Expression: + // AssignmentExpression + // Expression `,` AssignmentExpression + Comma, + + // NOTE: `Spread` is higher than `Comma` due to how it is parsed in |ElementList| + // SpreadElement: + // `...` AssignmentExpression + Spread, + + // AssignmentExpression: + // ConditionalExpression + // YieldExpression + // ArrowFunction + // AsyncArrowFunction + // LeftHandSideExpression `=` AssignmentExpression + // LeftHandSideExpression AssignmentOperator AssignmentExpression + // + // NOTE: AssignmentExpression is broken down into several precedences due to the requirements + // of the parenthesize rules. + + // AssignmentExpression: YieldExpression + // YieldExpression: + // `yield` + // `yield` AssignmentExpression + // `yield` `*` AssignmentExpression + Yield, + + // AssignmentExpression: LeftHandSideExpression `=` AssignmentExpression + // AssignmentExpression: LeftHandSideExpression AssignmentOperator AssignmentExpression + // AssignmentOperator: one of + // `*=` `/=` `%=` `+=` `-=` `<<=` `>>=` `>>>=` `&=` `^=` `|=` `**=` + Assignment, + + // NOTE: `Conditional` is considered higher than `Assignment` here, but in reality they have + // the same precedence. + // AssignmentExpression: ConditionalExpression + // ConditionalExpression: + // ShortCircuitExpression + // ShortCircuitExpression `?` AssignmentExpression `:` AssignmentExpression + // ShortCircuitExpression: + // LogicalORExpression + // CoalesceExpression + Conditional, + + // CoalesceExpression: + // CoalesceExpressionHead `??` BitwiseORExpression + // CoalesceExpressionHead: + // CoalesceExpression + // BitwiseORExpression + Coalesce = Conditional, // NOTE: This is wrong + + // LogicalORExpression: + // LogicalANDExpression + // LogicalORExpression `||` LogicalANDExpression + LogicalOR, + + // LogicalANDExpression: + // BitwiseORExpression + // LogicalANDExpression `&&` BitwiseORExpression + LogicalAND, + + // BitwiseORExpression: + // BitwiseXORExpression + // BitwiseORExpression `^` BitwiseXORExpression + BitwiseOR, + + // BitwiseXORExpression: + // BitwiseANDExpression + // BitwiseXORExpression `^` BitwiseANDExpression + BitwiseXOR, + + // BitwiseANDExpression: + // EqualityExpression + // BitwiseANDExpression `^` EqualityExpression + BitwiseAND, + + // EqualityExpression: + // RelationalExpression + // EqualityExpression `==` RelationalExpression + // EqualityExpression `!=` RelationalExpression + // EqualityExpression `===` RelationalExpression + // EqualityExpression `!==` RelationalExpression + Equality, + + // RelationalExpression: + // ShiftExpression + // RelationalExpression `<` ShiftExpression + // RelationalExpression `>` ShiftExpression + // RelationalExpression `<=` ShiftExpression + // RelationalExpression `>=` ShiftExpression + // RelationalExpression `instanceof` ShiftExpression + // RelationalExpression `in` ShiftExpression + // [+TypeScript] RelationalExpression `as` Type + Relational, + + // ShiftExpression: + // AdditiveExpression + // ShiftExpression `<<` AdditiveExpression + // ShiftExpression `>>` AdditiveExpression + // ShiftExpression `>>>` AdditiveExpression + Shift, + + // AdditiveExpression: + // MultiplicativeExpression + // AdditiveExpression `+` MultiplicativeExpression + // AdditiveExpression `-` MultiplicativeExpression + Additive, + + // MultiplicativeExpression: + // ExponentiationExpression + // MultiplicativeExpression MultiplicativeOperator ExponentiationExpression + // MultiplicativeOperator: one of `*`, `/`, `%` + Multiplicative, + + // ExponentiationExpression: + // UnaryExpression + // UpdateExpression `**` ExponentiationExpression + Exponentiation, + + // UnaryExpression: + // UpdateExpression + // `delete` UnaryExpression + // `void` UnaryExpression + // `typeof` UnaryExpression + // `+` UnaryExpression + // `-` UnaryExpression + // `~` UnaryExpression + // `!` UnaryExpression + // AwaitExpression + // UpdateExpression: // TODO: Do we need to investigate the precedence here? + // `++` UnaryExpression + // `--` UnaryExpression + Unary, + + // UpdateExpression: + // LeftHandSideExpression + // LeftHandSideExpression `++` + // LeftHandSideExpression `--` + Update, + + // LeftHandSideExpression: + // NewExpression + // CallExpression + // NewExpression: + // MemberExpression + // `new` NewExpression + LeftHandSide, + + // CallExpression: + // CoverCallExpressionAndAsyncArrowHead + // SuperCall + // ImportCall + // CallExpression Arguments + // CallExpression `[` Expression `]` + // CallExpression `.` IdentifierName + // CallExpression TemplateLiteral + // MemberExpression: + // PrimaryExpression + // MemberExpression `[` Expression `]` + // MemberExpression `.` IdentifierName + // MemberExpression TemplateLiteral + // SuperProperty + // MetaProperty + // `new` MemberExpression Arguments + Member, + + // TODO: JSXElement? + // PrimaryExpression: + // `this` + // IdentifierReference + // Literal + // ArrayLiteral + // ObjectLiteral + // FunctionExpression + // ClassExpression + // GeneratorExpression + // AsyncFunctionExpression + // AsyncGeneratorExpression + // RegularExpressionLiteral + // TemplateLiteral + // CoverParenthesizedExpressionAndArrowParameterList + Primary, + + Highest = Primary, + Lowest = Comma, + // -1 is lower than all other precedences. Returning it will cause binary expression + // parsing to stop. + Invalid = -1, +} + +export function getOperatorPrecedence( + nodeKind: SyntaxKind, + operatorKind: SyntaxKind, + hasArguments?: boolean +): OperatorPrecedence { + switch (nodeKind) { + case SyntaxKind.CommaListExpression: + return OperatorPrecedence.Comma; + + case SyntaxKind.SpreadElement: + return OperatorPrecedence.Spread; + + case SyntaxKind.YieldExpression: + return OperatorPrecedence.Yield; + + case SyntaxKind.ConditionalExpression: + return OperatorPrecedence.Conditional; + + case SyntaxKind.BinaryExpression: + switch (operatorKind) { + case SyntaxKind.CommaToken: + return OperatorPrecedence.Comma; + + case SyntaxKind.EqualsToken: + case SyntaxKind.PlusEqualsToken: + case SyntaxKind.MinusEqualsToken: + case SyntaxKind.AsteriskAsteriskEqualsToken: + case SyntaxKind.AsteriskEqualsToken: + case SyntaxKind.SlashEqualsToken: + case SyntaxKind.PercentEqualsToken: + case SyntaxKind.LessThanLessThanEqualsToken: + case SyntaxKind.GreaterThanGreaterThanEqualsToken: + case SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken: + case SyntaxKind.AmpersandEqualsToken: + case SyntaxKind.CaretEqualsToken: + case SyntaxKind.BarEqualsToken: + case SyntaxKind.BarBarEqualsToken: + case SyntaxKind.AmpersandAmpersandEqualsToken: + case SyntaxKind.QuestionQuestionEqualsToken: + return OperatorPrecedence.Assignment; + + default: + return getBinaryOperatorPrecedence(operatorKind); + } + + // TODO: Should prefix `++` and `--` be moved to the `Update` precedence? + case SyntaxKind.TypeAssertionExpression: + case SyntaxKind.NonNullExpression: + case SyntaxKind.PrefixUnaryExpression: + case SyntaxKind.TypeOfExpression: + case SyntaxKind.VoidExpression: + case SyntaxKind.DeleteExpression: + case SyntaxKind.AwaitExpression: + return OperatorPrecedence.Unary; + + case SyntaxKind.PostfixUnaryExpression: + return OperatorPrecedence.Update; + + case SyntaxKind.CallExpression: + return OperatorPrecedence.LeftHandSide; + + case SyntaxKind.NewExpression: + return hasArguments + ? OperatorPrecedence.Member + : OperatorPrecedence.LeftHandSide; + + case SyntaxKind.TaggedTemplateExpression: + case SyntaxKind.PropertyAccessExpression: + case SyntaxKind.ElementAccessExpression: + case SyntaxKind.MetaProperty: + return OperatorPrecedence.Member; + + case SyntaxKind.AsExpression: + return OperatorPrecedence.Relational; + + case SyntaxKind.ThisKeyword: + case SyntaxKind.SuperKeyword: + case SyntaxKind.Identifier: + case SyntaxKind.PrivateIdentifier: + case SyntaxKind.NullKeyword: + case SyntaxKind.TrueKeyword: + case SyntaxKind.FalseKeyword: + case SyntaxKind.NumericLiteral: + case SyntaxKind.BigIntLiteral: + case SyntaxKind.StringLiteral: + case SyntaxKind.ArrayLiteralExpression: + case SyntaxKind.ObjectLiteralExpression: + case SyntaxKind.FunctionExpression: + case SyntaxKind.ArrowFunction: + case SyntaxKind.ClassExpression: + case SyntaxKind.RegularExpressionLiteral: + case SyntaxKind.NoSubstitutionTemplateLiteral: + case SyntaxKind.TemplateExpression: + case SyntaxKind.ParenthesizedExpression: + case SyntaxKind.OmittedExpression: + case SyntaxKind.JsxElement: + case SyntaxKind.JsxSelfClosingElement: + case SyntaxKind.JsxFragment: + return OperatorPrecedence.Primary; + + default: + return OperatorPrecedence.Invalid; + } +} + +export function getBinaryOperatorPrecedence( + kind: SyntaxKind +): OperatorPrecedence { + switch (kind) { + case SyntaxKind.QuestionQuestionToken: + return OperatorPrecedence.Coalesce; + case SyntaxKind.BarBarToken: + return OperatorPrecedence.LogicalOR; + case SyntaxKind.AmpersandAmpersandToken: + return OperatorPrecedence.LogicalAND; + case SyntaxKind.BarToken: + return OperatorPrecedence.BitwiseOR; + case SyntaxKind.CaretToken: + return OperatorPrecedence.BitwiseXOR; + case SyntaxKind.AmpersandToken: + return OperatorPrecedence.BitwiseAND; + case SyntaxKind.EqualsEqualsToken: + case SyntaxKind.ExclamationEqualsToken: + case SyntaxKind.EqualsEqualsEqualsToken: + case SyntaxKind.ExclamationEqualsEqualsToken: + return OperatorPrecedence.Equality; + case SyntaxKind.LessThanToken: + case SyntaxKind.GreaterThanToken: + case SyntaxKind.LessThanEqualsToken: + case SyntaxKind.GreaterThanEqualsToken: + case SyntaxKind.InstanceOfKeyword: + case SyntaxKind.InKeyword: + case SyntaxKind.AsKeyword: + return OperatorPrecedence.Relational; + case SyntaxKind.LessThanLessThanToken: + case SyntaxKind.GreaterThanGreaterThanToken: + case SyntaxKind.GreaterThanGreaterThanGreaterThanToken: + return OperatorPrecedence.Shift; + case SyntaxKind.PlusToken: + case SyntaxKind.MinusToken: + return OperatorPrecedence.Additive; + case SyntaxKind.AsteriskToken: + case SyntaxKind.SlashToken: + case SyntaxKind.PercentToken: + return OperatorPrecedence.Multiplicative; + case SyntaxKind.AsteriskAsteriskToken: + return OperatorPrecedence.Exponentiation; + } + + // -1 is lower than all other precedences. Returning it will cause binary expression + // parsing to stop. + return -1; +} diff --git a/packages/tools/eslint-custom-rules/src/util/getThisExpression.ts b/packages/tools/eslint-custom-rules/src/util/getThisExpression.ts new file mode 100644 index 0000000000..1f726eeaea --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/getThisExpression.ts @@ -0,0 +1,21 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; + +export function getThisExpression( + node: TSESTree.Node +): TSESTree.ThisExpression | undefined { + while (node) { + if (node.type === AST_NODE_TYPES.CallExpression) { + node = node.callee; + } else if (node.type === AST_NODE_TYPES.ThisExpression) { + return node; + } else if (node.type === AST_NODE_TYPES.MemberExpression) { + node = node.object; + } else if (node.type === AST_NODE_TYPES.ChainExpression) { + node = node.expression; + } else { + break; + } + } + + return; +} diff --git a/packages/tools/eslint-custom-rules/src/util/getWrappingFixer.ts b/packages/tools/eslint-custom-rules/src/util/getWrappingFixer.ts new file mode 100644 index 0000000000..836d0aab0d --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/getWrappingFixer.ts @@ -0,0 +1,211 @@ +import { + ASTUtils, + AST_NODE_TYPES, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +interface WrappingFixerParams { + /** Source code. */ + sourceCode: Readonly; + /** The node we want to modify. */ + node: TSESTree.Node; + /** + * Descendant of `node` we want to preserve. + * Use this to replace some code with another. + * By default it's the node we are modifying (so nothing is removed). + * You can pass multiple nodes as an array. + */ + innerNode?: TSESTree.Node | TSESTree.Node[]; + /** + * The function which gets the code of the `innerNode` and returns some code around it. + * Receives multiple arguments if there are multiple innerNodes. + * E.g. ``code => `${code} != null` `` + */ + wrap: (...code: string[]) => string; +} + +/** + * Wraps node with some code. Adds parenthesis as necessary. + * @returns Fixer which adds the specified code and parens if necessary. + */ +export function getWrappingFixer( + params: WrappingFixerParams +): TSESLint.ReportFixFunction { + const { sourceCode, node, innerNode = node, wrap } = params; + const innerNodes = Array.isArray(innerNode) ? innerNode : [innerNode]; + + return (fixer): TSESLint.RuleFix => { + const innerCodes = innerNodes.map((innerNode) => { + let code = sourceCode.getText(innerNode); + + // check the inner expression's precedence + if (!isStrongPrecedenceNode(innerNode)) { + // the code we are adding might have stronger precedence than our wrapped node + // let's wrap our node in parens in case it has a weaker precedence than the code we are wrapping it in + code = `(${code})`; + } + + return code; + }); + + // do the wrapping + let code = wrap(...innerCodes); + + // check the outer expression's precedence + if (isWeakPrecedenceParent(node)) { + // we wrapped the node in some expression which very likely has a different precedence than original wrapped node + // let's wrap the whole expression in parens just in case + if (!ASTUtils.isParenthesized(node, sourceCode)) { + code = `(${code})`; + } + } + + // check if we need to insert semicolon + if (/^[`([]/.exec(code) && isMissingSemicolonBefore(node, sourceCode)) { + code = `;${code}`; + } + + return fixer.replaceText(node, code); + }; +} + +/** + * Check if a node will always have the same precedence if it's parent changes. + */ +function isStrongPrecedenceNode(innerNode: TSESTree.Node): boolean { + return ( + innerNode.type === AST_NODE_TYPES.Literal || + innerNode.type === AST_NODE_TYPES.Identifier || + innerNode.type === AST_NODE_TYPES.ArrayExpression || + innerNode.type === AST_NODE_TYPES.ObjectExpression || + innerNode.type === AST_NODE_TYPES.MemberExpression || + innerNode.type === AST_NODE_TYPES.CallExpression || + innerNode.type === AST_NODE_TYPES.NewExpression || + innerNode.type === AST_NODE_TYPES.TaggedTemplateExpression + ); +} + +/** + * Check if a node's parent could have different precedence if the node changes. + */ +function isWeakPrecedenceParent(node: TSESTree.Node): boolean { + const parent = node.parent!; + + if ( + parent.type === AST_NODE_TYPES.UpdateExpression || + parent.type === AST_NODE_TYPES.UnaryExpression || + parent.type === AST_NODE_TYPES.BinaryExpression || + parent.type === AST_NODE_TYPES.LogicalExpression || + parent.type === AST_NODE_TYPES.ConditionalExpression || + parent.type === AST_NODE_TYPES.AwaitExpression + ) { + return true; + } + + if ( + parent.type === AST_NODE_TYPES.MemberExpression && + parent.object === node + ) { + return true; + } + + if ( + (parent.type === AST_NODE_TYPES.CallExpression || + parent.type === AST_NODE_TYPES.NewExpression) && + parent.callee === node + ) { + return true; + } + + if ( + parent.type === AST_NODE_TYPES.TaggedTemplateExpression && + parent.tag === node + ) { + return true; + } + + return false; +} + +/** + * Returns true if a node is at the beginning of expression statement and the statement above doesn't end with semicolon. + * Doesn't check if the node begins with `(`, `[` or `` ` ``. + */ +function isMissingSemicolonBefore( + node: TSESTree.Node, + sourceCode: TSESLint.SourceCode +): boolean { + for (;;) { + const parent = node.parent!; + + if (parent.type === AST_NODE_TYPES.ExpressionStatement) { + const block = parent.parent!; + if ( + block.type === AST_NODE_TYPES.Program || + block.type === AST_NODE_TYPES.BlockStatement + ) { + // parent is an expression statement in a block + const statementIndex = block.body.indexOf(parent); + const previousStatement = block.body[statementIndex - 1]; + if ( + statementIndex > 0 && + sourceCode.getLastToken(previousStatement)!.value !== ';' + ) { + return true; + } + } + } + + if (!isLeftHandSide(node)) { + return false; + } + + node = parent; + } +} + +/** + * Checks if a node is LHS of an operator. + */ +function isLeftHandSide(node: TSESTree.Node): boolean { + const parent = node.parent!; + + // a++ + if (parent.type === AST_NODE_TYPES.UpdateExpression) { + return true; + } + + // a + b + if ( + (parent.type === AST_NODE_TYPES.BinaryExpression || + parent.type === AST_NODE_TYPES.LogicalExpression || + parent.type === AST_NODE_TYPES.AssignmentExpression) && + node === parent.left + ) { + return true; + } + + // a ? b : c + if ( + parent.type === AST_NODE_TYPES.ConditionalExpression && + node === parent.test + ) { + return true; + } + + // a(b) + if (parent.type === AST_NODE_TYPES.CallExpression && node === parent.callee) { + return true; + } + + // a`b` + if ( + parent.type === AST_NODE_TYPES.TaggedTemplateExpression && + node === parent.tag + ) { + return true; + } + + return false; +} diff --git a/packages/tools/eslint-custom-rules/src/util/index.ts b/packages/tools/eslint-custom-rules/src/util/index.ts new file mode 100644 index 0000000000..78ebc04de5 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/index.ts @@ -0,0 +1,32 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +export * from '../type-utils'; +export * from './astUtils'; +export * from './collectUnusedVariables'; +export * from './createRule'; +export * from './getFunctionHeadLoc'; +export * from './getThisExpression'; +export * from './getWrappingFixer'; +export * from './misc'; +export * from './objectIterators'; +export type { InferMessageIdsTypeFromRule, InferOptionsTypeFromRule }; +export { + applyDefault, + deepMerge, + isObjectNotArray, + getParserServices, + nullThrows, + NullThrowsReasons, +}; + +const { + applyDefault, + deepMerge, + isObjectNotArray, + getParserServices, + nullThrows, + NullThrowsReasons, +} = ESLintUtils; +type InferMessageIdsTypeFromRule = + ESLintUtils.InferMessageIdsTypeFromRule; +type InferOptionsTypeFromRule = ESLintUtils.InferOptionsTypeFromRule; diff --git a/packages/tools/eslint-custom-rules/src/util/misc.ts b/packages/tools/eslint-custom-rules/src/util/misc.ts new file mode 100644 index 0000000000..2d0be015a3 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/misc.ts @@ -0,0 +1,184 @@ +/** + * @fileoverview Really small utility functions that didn't deserve their own files + */ + +import { requiresQuoting } from '@typescript-eslint/type-utils'; +import { AST_NODE_TYPES, TSESLint, TSESTree } from '@typescript-eslint/utils'; + +/** + * Check if the context file name is *.d.ts or *.d.tsx + */ +function isDefinitionFile(fileName: string): boolean { + return /\.d\.tsx?$/i.test(fileName || ''); +} + +/** + * Upper cases the first character or the string + */ +function upperCaseFirst(str: string): string { + return str[0].toUpperCase() + str.slice(1); +} + +function arrayGroupByToMap( + array: T[], + getKey: (item: T) => Key +): Map { + const groups = new Map(); + + for (const item of array) { + const key = getKey(item); + const existing = groups.get(key); + + if (existing) { + existing.push(item); + } else { + groups.set(key, [item]); + } + } + + return groups; +} + +/** Return true if both parameters are equal. */ +type Equal = (a: T, b: T) => boolean; + +function arraysAreEqual( + a: T[] | undefined, + b: T[] | undefined, + eq: (a: T, b: T) => boolean +): boolean { + return ( + a === b || + (a !== undefined && + b !== undefined && + a.length === b.length && + a.every((x, idx) => eq(x, b[idx]))) + ); +} + +/** Returns the first non-`undefined` result. */ +function findFirstResult( + inputs: T[], + getResult: (t: T) => U | undefined +): U | undefined { + for (const element of inputs) { + const result = getResult(element); + if (result !== undefined) { + return result; + } + } + return undefined; +} + +/** + * Gets a string representation of the name of the index signature. + */ +function getNameFromIndexSignature(node: TSESTree.TSIndexSignature): string { + const propName: TSESTree.PropertyName | undefined = node.parameters.find( + (parameter: TSESTree.Parameter): parameter is TSESTree.Identifier => + parameter.type === AST_NODE_TYPES.Identifier + ); + return propName ? propName.name : '(index signature)'; +} + +enum MemberNameType { + Private = 1, + Quoted = 2, + Normal = 3, + Expression = 4, +} + +/** + * Gets a string name representation of the name of the given MethodDefinition + * or PropertyDefinition node, with handling for computed property names. + */ +function getNameFromMember( + member: + | TSESTree.MethodDefinition + | TSESTree.TSMethodSignature + | TSESTree.TSAbstractMethodDefinition + | TSESTree.PropertyDefinition + | TSESTree.TSAbstractPropertyDefinition + | TSESTree.Property + | TSESTree.TSPropertySignature, + sourceCode: TSESLint.SourceCode +): { type: MemberNameType; name: string } { + if (member.key.type === AST_NODE_TYPES.Identifier) { + return { + type: MemberNameType.Normal, + name: member.key.name, + }; + } + if (member.key.type === AST_NODE_TYPES.PrivateIdentifier) { + return { + type: MemberNameType.Private, + name: `#${member.key.name}`, + }; + } + if (member.key.type === AST_NODE_TYPES.Literal) { + const name = `${member.key.value}`; + if (requiresQuoting(name)) { + return { + type: MemberNameType.Quoted, + name: `"${name}"`, + }; + } else { + return { + type: MemberNameType.Normal, + name, + }; + } + } + + return { + type: MemberNameType.Expression, + name: sourceCode.text.slice(...member.key.range), + }; +} + +type ExcludeKeys< + TObj extends Record, + TKeys extends keyof TObj +> = { [k in Exclude]: TObj[k] }; +type RequireKeys< + TObj extends Record, + TKeys extends keyof TObj +> = ExcludeKeys & { [k in TKeys]-?: Exclude }; + +function getEnumNames(myEnum: Record): T[] { + return Object.keys(myEnum).filter((x) => isNaN(parseInt(x))) as T[]; +} + +/** + * Given an array of words, returns an English-friendly concatenation, separated with commas, with + * the `and` clause inserted before the last item. + * + * Example: ['foo', 'bar', 'baz' ] returns the string "foo, bar, and baz". + */ +function formatWordList(words: string[]): string { + if (!words?.length) { + return ''; + } + + if (words.length === 1) { + return words[0]; + } + + return [words.slice(0, -1).join(', '), words.slice(-1)[0]].join(' and '); +} + +export { + arrayGroupByToMap, + arraysAreEqual, + Equal, + ExcludeKeys, + findFirstResult, + formatWordList, + getEnumNames, + getNameFromIndexSignature, + getNameFromMember, + isDefinitionFile, + MemberNameType, + RequireKeys, + upperCaseFirst, +}; diff --git a/packages/tools/eslint-custom-rules/src/util/objectIterators.ts b/packages/tools/eslint-custom-rules/src/util/objectIterators.ts new file mode 100644 index 0000000000..1ddb2b4775 --- /dev/null +++ b/packages/tools/eslint-custom-rules/src/util/objectIterators.ts @@ -0,0 +1,34 @@ +function objectForEachKey>( + obj: T, + callback: (key: keyof T) => void +): void { + const keys = Object.keys(obj); + for (const key of keys) { + callback(key); + } +} + +function objectMapKey, TReturn>( + obj: T, + callback: (key: keyof T) => TReturn +): TReturn[] { + const values: TReturn[] = []; + objectForEachKey(obj, (key) => { + values.push(callback(key)); + }); + return values; +} + +function objectReduceKey, TAccumulator>( + obj: T, + callback: (acc: TAccumulator, key: keyof T) => TAccumulator, + initial: TAccumulator +): TAccumulator { + let accumulator = initial; + objectForEachKey(obj, (key) => { + accumulator = callback(accumulator, key); + }); + return accumulator; +} + +export { objectForEachKey, objectMapKey, objectReduceKey }; diff --git a/packages/tools/eslint-custom-rules/src/utils/isTypeReadonly.ts b/packages/tools/eslint-custom-rules/src/utils/isTypeReadonly.ts index 1f246372eb..28c4e95957 100644 --- a/packages/tools/eslint-custom-rules/src/utils/isTypeReadonly.ts +++ b/packages/tools/eslint-custom-rules/src/utils/isTypeReadonly.ts @@ -18,9 +18,27 @@ const enum Readonlyness { /** the type is mutable */ Mutable = 2, /** the type is readonly */ - Immutable = 3, + Readonly = 3, } +export interface ReadonlynessOptions { + readonly treatMethodsAsReadonly?: boolean; +} + +export const readonlynessOptionsSchema = { + type: 'object', + additionalProperties: false, + properties: { + treatMethodsAsReadonly: { + type: 'boolean', + }, + }, +}; + +export const readonlynessOptionsDefaults: ReadonlynessOptions = { + treatMethodsAsReadonly: false, +}; + const checkTypeArguments = ( checker: ts.TypeChecker, seenTypes: Set, @@ -31,7 +49,7 @@ const checkTypeArguments = ( // - tuples require at least 1 type argument // - ReadonlyArray requires at least 1 type argument /* istanbul ignore if */ if (typeArguments.length === 0) { - return Readonlyness.Immutable; + return Readonlyness.Readonly; } // validate the element types are also readonly @@ -46,7 +64,7 @@ const checkTypeArguments = ( ) { return Readonlyness.Mutable; } - return Readonlyness.Immutable; + return Readonlyness.Readonly; }; const isTypeReadonlyArrayOrTuple = ( @@ -87,7 +105,7 @@ const isTypeReadonlyObject = ( const indexInfo = checker.getIndexInfoOfType(type, kind); if (indexInfo !== undefined) { return indexInfo.isReadonly - ? Readonlyness.Immutable + ? Readonlyness.Readonly : Readonlyness.Mutable; } @@ -141,7 +159,7 @@ const isTypeReadonlyObject = ( return isNumberIndexSigReadonly; } - return Readonlyness.Immutable; + return Readonlyness.Readonly; }; // a helper function to ensure the seenTypes map is always passed down, except by the external caller @@ -149,7 +167,7 @@ const isTypeReadonlyRecurser = ( checker: ts.TypeChecker, type: ts.Type, seenTypes: Set -): Readonlyness.Immutable | Readonlyness.Mutable => { +): Readonlyness.Readonly | Readonlyness.Mutable => { if (type === undefined || type === null) { throw new Error("type shouldn't be nullable"); } @@ -160,7 +178,7 @@ const isTypeReadonlyRecurser = ( return unionTypeParts(type) .filter((t) => !seenTypes.has(t)) .every((t) => isTypeReadonlyRecurser(checker, t, seenTypes)) - ? Readonlyness.Immutable + ? Readonlyness.Readonly : Readonlyness.Mutable; } @@ -169,14 +187,14 @@ const isTypeReadonlyRecurser = ( return intersectionTypeParts(type) .filter((t) => !seenTypes.has(t)) .every((t) => isTypeReadonlyRecurser(checker, t, seenTypes)) - ? Readonlyness.Immutable + ? Readonlyness.Readonly : Readonlyness.Mutable; } // all non-object, non-intersection types are readonly. // this should only be primitive types if (!isObjectType(type) && !isUnionOrIntersectionType(type)) { - return Readonlyness.Immutable; + return Readonlyness.Readonly; } // pure function types are readonly @@ -184,7 +202,7 @@ const isTypeReadonlyRecurser = ( type.getCallSignatures().length > 0 && type.getProperties().length === 0 ) { - return Readonlyness.Immutable; + return Readonlyness.Readonly; } // Array @@ -238,4 +256,4 @@ export const isTypeReadonly = ( checker: ts.TypeChecker, type: ts.Type ): boolean => - isTypeReadonlyRecurser(checker, type, new Set()) === Readonlyness.Immutable; + isTypeReadonlyRecurser(checker, type, new Set()) === Readonlyness.Readonly;