diff --git a/packages/eslint-plugin/src/rules/enum-utils/shared.ts b/packages/eslint-plugin/src/rules/enum-utils/shared.ts index 39fca8af911..b2a93b4f116 100644 --- a/packages/eslint-plugin/src/rules/enum-utils/shared.ts +++ b/packages/eslint-plugin/src/rules/enum-utils/shared.ts @@ -20,6 +20,23 @@ function getBaseEnumType(typeChecker: ts.TypeChecker, type: ts.Type): ts.Type { return typeChecker.getTypeAtLocation(symbol.valueDeclaration!.parent); } +/** + * Retrieve only the Enum literals from a type. for example: + * - 123 --> [] + * - {} --> [] + * - Fruit.Apple --> [Fruit.Apple] + * - Fruit.Apple | Vegetable.Lettuce --> [Fruit.Apple, Vegetable.Lettuce] + * - Fruit.Apple | Vegetable.Lettuce | 123 --> [Fruit.Apple, Vegetable.Lettuce] + * - T extends Fruit --> [Fruit] + */ +export function getEnumLiterals(type: ts.Type): ts.LiteralType[] { + return tsutils + .unionTypeParts(type) + .filter((subType): subType is ts.LiteralType => + isTypeFlagSet(subType, ts.TypeFlags.EnumLiteral), + ); +} + /** * A type can have 0 or more enum types. For example: * - 123 --> [] @@ -33,8 +50,55 @@ export function getEnumTypes( typeChecker: ts.TypeChecker, type: ts.Type, ): ts.Type[] { - return tsutils - .unionTypeParts(type) - .filter(subType => isTypeFlagSet(subType, ts.TypeFlags.EnumLiteral)) - .map(type => getBaseEnumType(typeChecker, type)); + return getEnumLiterals(type).map(type => getBaseEnumType(typeChecker, type)); +} + +/** + * Returns the enum key that matches the given literal node, or null if none + * match. For example: + * ```ts + * enum Fruit { + * Apple = 'apple', + * Banana = 'banana', + * } + * + * getEnumKeyForLiteral([Fruit.Apple, Fruit.Banana], 'apple') --> 'Fruit.Apple' + * getEnumKeyForLiteral([Fruit.Apple, Fruit.Banana], 'banana') --> 'Fruit.Banana' + * getEnumKeyForLiteral([Fruit.Apple, Fruit.Banana], 'cherry') --> null + * ``` + */ +export function getEnumKeyForLiteral( + enumLiterals: ts.LiteralType[], + literal: unknown, +): string | null { + for (const enumLiteral of enumLiterals) { + if (enumLiteral.value === literal) { + const { symbol } = enumLiteral; + + const memberDeclaration = symbol.valueDeclaration as ts.EnumMember; + const enumDeclaration = memberDeclaration.parent; + + const memberNameIdentifier = memberDeclaration.name; + const enumName = enumDeclaration.name.text; + + switch (memberNameIdentifier.kind) { + case ts.SyntaxKind.Identifier: + return `${enumName}.${memberNameIdentifier.text}`; + + case ts.SyntaxKind.StringLiteral: { + const memberName = memberNameIdentifier.text.replace(/'/g, "\\'"); + + return `${enumName}['${memberName}']`; + } + + case ts.SyntaxKind.ComputedPropertyName: + return `${enumName}[${memberNameIdentifier.expression.getText()}]`; + + default: + break; + } + } + } + + return null; } diff --git a/packages/eslint-plugin/src/rules/no-unsafe-enum-comparison.ts b/packages/eslint-plugin/src/rules/no-unsafe-enum-comparison.ts index ca08f2f7a2c..2366ca54ff4 100644 --- a/packages/eslint-plugin/src/rules/no-unsafe-enum-comparison.ts +++ b/packages/eslint-plugin/src/rules/no-unsafe-enum-comparison.ts @@ -1,9 +1,13 @@ -import type { TSESTree } from '@typescript-eslint/utils'; +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import * as tsutils from 'ts-api-utils'; import * as ts from 'typescript'; -import { createRule, getParserServices } from '../util'; -import { getEnumTypes } from './enum-utils/shared'; +import { createRule, getParserServices, getStaticValue } from '../util'; +import { + getEnumKeyForLiteral, + getEnumLiterals, + getEnumTypes, +} from './enum-utils/shared'; /** * @returns Whether the right type is an unsafe comparison against any left type. @@ -39,6 +43,7 @@ function getEnumValueType(type: ts.Type): ts.TypeFlags | undefined { export default createRule({ name: 'no-unsafe-enum-comparison', meta: { + hasSuggestions: true, type: 'suggestion', docs: { description: 'Disallow comparing an enum value with a non-enum value', @@ -48,6 +53,7 @@ export default createRule({ messages: { mismatched: 'The two values in this comparison do not have a shared enum type.', + replaceValueWithEnum: 'Replace with an enum value comparison.', }, schema: [], }, @@ -107,6 +113,43 @@ export default createRule({ context.report({ messageId: 'mismatched', node, + suggest: [ + { + messageId: 'replaceValueWithEnum', + fix(fixer): TSESLint.RuleFix | null { + // Replace the right side with an enum key if possible: + // + // ```ts + // Fruit.Apple === 'apple'; // Fruit.Apple === Fruit.Apple + // ``` + const leftEnumKey = getEnumKeyForLiteral( + getEnumLiterals(left), + getStaticValue(node.right)?.value, + ); + + if (leftEnumKey) { + return fixer.replaceText(node.right, leftEnumKey); + } + + // Replace the left side with an enum key if possible: + // + // ```ts + // declare const fruit: Fruit; + // 'apple' === Fruit.Apple; // Fruit.Apple === Fruit.Apple + // ``` + const rightEnumKey = getEnumKeyForLiteral( + getEnumLiterals(right), + getStaticValue(node.left)?.value, + ); + + if (rightEnumKey) { + return fixer.replaceText(node.left, rightEnumKey); + } + + return null; + }, + }, + ], }); } }, diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-enum-comparison.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-enum-comparison.test.ts index c31b742160c..ff1211b257b 100644 --- a/packages/eslint-plugin/tests/rules/no-unsafe-enum-comparison.test.ts +++ b/packages/eslint-plugin/tests/rules/no-unsafe-enum-comparison.test.ts @@ -565,5 +565,386 @@ ruleTester.run('strict-enums-comparison', rule, { `, errors: [{ messageId: 'mismatched' }], }, + { + code: ` + enum Str { + A = 'a', + B = 'b', + } + declare const str: Str; + str === 'b'; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum Str { + A = 'a', + B = 'b', + } + declare const str: Str; + str === Str.B; + `, + }, + ], + }, + ], + }, + { + code: ` + enum Str { + A = 'a', + AB = 'ab', + } + declare const str: Str; + str === 'a' + 'b'; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum Str { + A = 'a', + AB = 'ab', + } + declare const str: Str; + str === Str.AB; + `, + }, + ], + }, + ], + }, + { + code: ` + enum Num { + A = 1, + B = 2, + } + declare const num: Num; + 1 === num; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum Num { + A = 1, + B = 2, + } + declare const num: Num; + Num.A === num; + `, + }, + ], + }, + ], + }, + { + code: ` + enum Num { + A = 1, + B = 2, + } + declare const num: Num; + 1 /* with */ === /* comment */ num; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum Num { + A = 1, + B = 2, + } + declare const num: Num; + Num.A /* with */ === /* comment */ num; + `, + }, + ], + }, + ], + }, + { + code: ` + enum Num { + A = 1, + B = 2, + } + declare const num: Num; + 1 + 1 === num; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum Num { + A = 1, + B = 2, + } + declare const num: Num; + Num.B === num; + `, + }, + ], + }, + ], + }, + { + code: ` + enum Mixed { + A = 1, + B = 'b', + } + declare const mixed: Mixed; + mixed === 1; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum Mixed { + A = 1, + B = 'b', + } + declare const mixed: Mixed; + mixed === Mixed.A; + `, + }, + ], + }, + ], + }, + { + code: ` + enum Mixed { + A = 1, + B = 'b', + } + declare const mixed: Mixed; + mixed === 'b'; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum Mixed { + A = 1, + B = 'b', + } + declare const mixed: Mixed; + mixed === Mixed.B; + `, + }, + ], + }, + ], + }, + { + code: ` + enum StringKey { + 'test-key' /* with comment */ = 1, + } + declare const stringKey: StringKey; + stringKey === 1; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum StringKey { + 'test-key' /* with comment */ = 1, + } + declare const stringKey: StringKey; + stringKey === StringKey['test-key']; + `, + }, + ], + }, + ], + }, + { + code: ` + enum StringKey { + "key-'with-single'-quotes" = 1, + } + declare const stringKey: StringKey; + stringKey === 1; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum StringKey { + "key-'with-single'-quotes" = 1, + } + declare const stringKey: StringKey; + stringKey === StringKey['key-\\'with-single\\'-quotes']; + `, + }, + ], + }, + ], + }, + { + code: ` + enum StringKey { + 'key-"with-double"-quotes' = 1, + } + declare const stringKey: StringKey; + stringKey === 1; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum StringKey { + 'key-"with-double"-quotes' = 1, + } + declare const stringKey: StringKey; + stringKey === StringKey['key-"with-double"-quotes']; + `, + }, + ], + }, + ], + }, + { + code: ` + enum StringKey { + 'key-\`with-backticks\`-quotes' = 1, + } + declare const stringKey: StringKey; + stringKey === 1; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum StringKey { + 'key-\`with-backticks\`-quotes' = 1, + } + declare const stringKey: StringKey; + stringKey === StringKey['key-\`with-backticks\`-quotes']; + `, + }, + ], + }, + ], + }, + { + code: ` + enum ComputedKey { + ['test-key' /* with comment */] = 1, + } + declare const computedKey: ComputedKey; + computedKey === 1; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum ComputedKey { + ['test-key' /* with comment */] = 1, + } + declare const computedKey: ComputedKey; + computedKey === ComputedKey['test-key']; + `, + }, + ], + }, + ], + }, + { + code: ` + enum ComputedKey { + [\`test-key\` /* with comment */] = 1, + } + declare const computedKey: ComputedKey; + computedKey === 1; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum ComputedKey { + [\`test-key\` /* with comment */] = 1, + } + declare const computedKey: ComputedKey; + computedKey === ComputedKey[\`test-key\`]; + `, + }, + ], + }, + ], + }, + { + code: ` + enum ComputedKey { + [\`test- + key\` /* with comment */] = 1, + } + declare const computedKey: ComputedKey; + computedKey === 1; + `, + errors: [ + { + messageId: 'mismatched', + suggestions: [ + { + messageId: 'replaceValueWithEnum', + output: ` + enum ComputedKey { + [\`test- + key\` /* with comment */] = 1, + } + declare const computedKey: ComputedKey; + computedKey === ComputedKey[\`test- + key\`]; + `, + }, + ], + }, + ], + }, ], });