diff --git a/packages/eslint-plugin/src/rules/enum-utils/shared.ts b/packages/eslint-plugin/src/rules/enum-utils/shared.ts index 39fca8af9113..b2a93b4f1162 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 ca08f2f7a2cd..2366ca54ff4f 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 c31b742160ce..ff1211b257b4 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\`]; + `, + }, + ], + }, + ], + }, ], }); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy