From bcab2acd0e2fcf55500ffc2d214f2ce9a8654fa8 Mon Sep 17 00:00:00 2001 From: Remco Haszing Date: Wed, 25 Nov 2020 21:19:53 +0100 Subject: [PATCH] feat(eslint-plugin): [no-duplicate-union-intersection] add rule --- .../rules/no-duplicate-union-intersection.ts | 233 ++++++++++++++++++ .../no-duplicate-union-intersection.test.ts | 178 +++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 packages/eslint-plugin/src/rules/no-duplicate-union-intersection.ts create mode 100644 packages/eslint-plugin/tests/rules/no-duplicate-union-intersection.test.ts diff --git a/packages/eslint-plugin/src/rules/no-duplicate-union-intersection.ts b/packages/eslint-plugin/src/rules/no-duplicate-union-intersection.ts new file mode 100644 index 000000000000..bfcaf1dc373e --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-duplicate-union-intersection.ts @@ -0,0 +1,233 @@ +import { createRule } from '../util'; +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; + +function unpackType(node?: TSESTree.Node): TSESTree.Node | undefined { + if (node?.type === AST_NODE_TYPES.TSParenthesizedType) { + return unpackType(node.typeAnnotation); + } + return node; +} + +function compareArray(a?: T[], b?: T[]): boolean { + if (a == null) { + return b == null; + } + if (b == null) { + return false; + } + if (a.length !== b.length) { + return false; + } + return a.every((entry, index) => compareNode(entry, b[index])); +} + +function compareLiteral(a: TSESTree.Literal, b: TSESTree.Literal): boolean { + return a.value === b.value; +} + +function compareIdentifier( + a: TSESTree.Identifier, + b: TSESTree.Identifier, +): boolean { + return a.name === b.name && compareNode(a.typeAnnotation, b.typeAnnotation); +} + +function compareTSFunctionType( + a: TSESTree.TSFunctionType, + b: TSESTree.TSFunctionType, +): boolean { + // console.log(a.params.map(), b.params); + return ( + compareNode(a.returnType, b.returnType) && + compareNode(a.typeParameters, b.typeParameters) && + compareArray(a.params, b.params) + ); +} + +function compareTSLiteralType( + a: TSESTree.TSLiteralType, + b: TSESTree.TSLiteralType, +): boolean { + return compareNode(a.literal, b.literal); +} + +function compareTSQualifiedName( + a: TSESTree.TSQualifiedName, + b: TSESTree.TSQualifiedName, +): boolean { + return compareNode(a.left, b.left) && compareNode(a.right, b.right); +} + +function compareTSTypeAnnotation( + a: TSESTree.TSTypeAnnotation, + b: TSESTree.TSTypeAnnotation, +): boolean { + return compareNode(a.typeAnnotation, b.typeAnnotation); +} + +// function compareTSTypeLiteral( +// a: TSESTree.TSTypeLiteral, +// b: TSESTree.TSTypeLiteral, +// ): boolean { +// const aMembers: TSESTree.TSPropertySignatureNonComputedName[] = []; +// const bMembers: TSESTree.TSPropertySignatureNonComputedName[] = []; +// for (const member of a.members) { +// if (member.type !== AST_NODE_TYPES.TSPropertySignature || member.computed) { +// return false; +// } +// aMembers.push(member); +// } +// for (const member of b.members) { +// if (member.type !== AST_NODE_TYPES.TSPropertySignature || member.computed) { +// return false; +// } +// bMembers.push(member); +// } + +// return compareArray( +// aMembers.sort(({ key: k1 }, { key: k2 }) => { +// return ( +// k1.type.localeCompare(k2.type) || +// k1.type === AST_NODE_TYPES.TS ? +// ); +// }), +// b.members, +// ); +// } + +function compareTSTypeParameter( + a: TSESTree.TSTypeParameter, + b: TSESTree.TSTypeParameter, +): boolean { + return compareNode(a.name, b.name); +} + +function compareTSTypeParameterInstantiation( + a: TSESTree.TSTypeParameterInstantiation, + b: TSESTree.TSTypeParameterInstantiation, +): boolean { + return compareArray(a.params, b.params); +} + +function compareTSTypeParameterDeclaration( + a: TSESTree.TSTypeParameterDeclaration, + b: TSESTree.TSTypeParameterDeclaration, +): boolean { + return compareArray(a.params, b.params); +} + +function compareTSTypeReference( + a: TSESTree.TSTypeReference, + b: TSESTree.TSTypeReference, +): boolean { + return ( + compareNode(a.typeName, b.typeName) && + compareNode(a.typeParameters, b.typeParameters) + ); +} + +function compareNode(c?: TSESTree.Node, d?: TSESTree.Node): boolean { + const a = unpackType(c); + const b = unpackType(d); + if (a == null) { + return b == null; + } + if (b == null) { + return false; + } + if (a.type !== b.type) { + return false; + } + // At this point it’s safe to use `b as typeof a`. + + switch (a.type) { + case AST_NODE_TYPES.TSAnyKeyword: + case AST_NODE_TYPES.TSBooleanKeyword: + case AST_NODE_TYPES.TSBigIntKeyword: + case AST_NODE_TYPES.TSNeverKeyword: + case AST_NODE_TYPES.TSNullKeyword: + case AST_NODE_TYPES.TSNumberKeyword: + case AST_NODE_TYPES.TSObjectKeyword: + case AST_NODE_TYPES.TSStringKeyword: + case AST_NODE_TYPES.TSSymbolKeyword: + case AST_NODE_TYPES.TSUndefinedKeyword: + case AST_NODE_TYPES.TSUnknownKeyword: + case AST_NODE_TYPES.TSVoidKeyword: + return true; + case AST_NODE_TYPES.Identifier: + return compareIdentifier(a, b as typeof a); + case AST_NODE_TYPES.Literal: + return compareLiteral(a, b as typeof a); + case AST_NODE_TYPES.TSFunctionType: + return compareTSFunctionType(a, b as typeof a); + case AST_NODE_TYPES.TSLiteralType: + return compareTSLiteralType(a, b as typeof a); + case AST_NODE_TYPES.TSQualifiedName: + return compareTSQualifiedName(a, b as typeof a); + case AST_NODE_TYPES.TSTypeAnnotation: + return compareTSTypeAnnotation(a, b as typeof a); + // case AST_NODE_TYPES.TSTypeLiteral: + // return compareTSTypeLiteral(a, b as typeof a); + case AST_NODE_TYPES.TSTypeParameter: + return compareTSTypeParameter(a, b as typeof a); + case AST_NODE_TYPES.TSTypeParameterInstantiation: + return compareTSTypeParameterInstantiation(a, b as typeof a); + case AST_NODE_TYPES.TSTypeParameterDeclaration: + return compareTSTypeParameterDeclaration(a, b as typeof a); + case AST_NODE_TYPES.TSTypeReference: + return compareTSTypeReference(a, b as typeof a); + default: + return false; + } +} + +export default createRule({ + name: 'no-duplicate-union-intersection', + meta: { + type: 'suggestion', + docs: { + description: 'Enforce or disallow the use of the record type', + category: 'Possible Errors', + recommended: false, + }, + messages: { duplicate: 'This duplicate should be removed' }, + fixable: 'code', + schema: [], + }, + defaultOptions: [], + create(context) { + function findDuplicates({ + types, + }: TSESTree.TSIntersectionType | TSESTree.TSUnionType): void { + const reported = new Set(); + + types.forEach((a, aIndex) => { + types.slice(aIndex + 1).forEach((b, bIndex) => { + // No need to report the same node twice. + if (reported.has(b)) { + return; + } + if (!compareNode(a, b)) { + return; + } + reported.add(b); + + context.report({ + node: b, + messageId: 'duplicate', + fix: fixer => + fixer.removeRange([types[aIndex + bIndex].range[1], b.range[1]]), + }); + }); + }); + } + + return { + TSIntersectionType: findDuplicates, + TSUnionType: findDuplicates, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-union-intersection.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-union-intersection.test.ts new file mode 100644 index 000000000000..046286b2afb4 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-duplicate-union-intersection.test.ts @@ -0,0 +1,178 @@ +import rule from '../../src/rules/no-duplicate-union-intersection'; +import { RuleTester, noFormat } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('no-duplicate-union-intersection', rule, { + valid: [ + 'type T = any;', + 'type T = bigint;', + 'type T = boolean;', + 'type T = null;', + 'type T = never;', + 'type T = number;', + 'type T = object;', + 'type T = string;', + 'type T = symbol;', + 'type T = undefined;', + 'type T = unknown;', + 'type T = void;', + 'type T = 1;', + 'type T = 1 | 2;', + "type T = 1 | '1';", + "type T = 'A' | 'B';", + 'type T = A | B;', + 'type T = A | A;', + 'type T = A | A;', + 'type T = A | A;', + 'type T = A | A;', + 'type T = A.A | A.B;', + 'type T = A.A | B.A;', + 'type T = A.A.A | A.A.B;', + 'type T = { a: string } | { b: string };', + 'type T = (() => void) | (() => number);', + 'type T = (() => void) | (() => void);', + ], + invalid: [ + { + code: 'type T = any | any;', + output: 'type T = any;', + errors: [{ messageId: 'duplicate', line: 1, column: 16 }], + }, + { + code: 'type T = bigint | bigint;', + output: 'type T = bigint;', + errors: [{ messageId: 'duplicate', line: 1, column: 19 }], + }, + { + code: 'type T = boolean | boolean;', + output: 'type T = boolean;', + errors: [{ messageId: 'duplicate', line: 1, column: 20 }], + }, + { + code: 'type T = never | never;', + output: 'type T = never;', + errors: [{ messageId: 'duplicate', line: 1, column: 18 }], + }, + { + code: 'type T = null | null;', + output: 'type T = null;', + errors: [{ messageId: 'duplicate', line: 1, column: 17 }], + }, + { + code: 'type T = number | number;', + output: 'type T = number;', + errors: [{ messageId: 'duplicate', line: 1, column: 19 }], + }, + { + code: 'type T = object | object;', + output: 'type T = object;', + errors: [{ messageId: 'duplicate', line: 1, column: 19 }], + }, + { + code: 'type T = string | string;', + output: 'type T = string;', + errors: [{ messageId: 'duplicate', line: 1, column: 19 }], + }, + { + code: 'type T = symbol | symbol;', + output: 'type T = symbol;', + errors: [{ messageId: 'duplicate', line: 1, column: 19 }], + }, + { + code: 'type T = undefined | undefined;', + output: 'type T = undefined;', + errors: [{ messageId: 'duplicate', line: 1, column: 22 }], + }, + { + code: 'type T = unknown | unknown;', + output: 'type T = unknown;', + errors: [{ messageId: 'duplicate', line: 1, column: 20 }], + }, + { + code: 'type T = void | void;', + output: 'type T = void;', + errors: [{ messageId: 'duplicate', line: 1, column: 17 }], + }, + { + code: 'type T = 1 | 1;', + output: 'type T = 1;', + errors: [{ messageId: 'duplicate', line: 1, column: 14 }], + }, + { + code: 'type T = 1 | 1 | 1;', + // The autofixer won’t remove bordering text ranges in one run. + output: 'type T = 1 | 1;', + errors: [ + { messageId: 'duplicate', line: 1, column: 14 }, + { messageId: 'duplicate', line: 1, column: 18 }, + ], + }, + { + code: 'type T = 1 | 2 | 1 | 3 | 1;', + output: 'type T = 1 | 2 | 3;', + errors: [ + { messageId: 'duplicate', line: 1, column: 18 }, + { messageId: 'duplicate', line: 1, column: 26 }, + ], + }, + { + code: "type T = 'A' | 'A';", + output: "type T = 'A';", + errors: [{ messageId: 'duplicate', line: 1, column: 16 }], + }, + { + code: 'type T = A | A;', + output: 'type T = A;', + errors: [{ messageId: 'duplicate', line: 1, column: 14 }], + }, + { + code: 'type T = A | A;', + output: 'type T = A;', + errors: [{ messageId: 'duplicate', line: 1, column: 17 }], + }, + { + code: 'type T = A.A | A.A;', + output: 'type T = A.A;', + errors: [{ messageId: 'duplicate', line: 1, column: 16 }], + }, + { + code: 'type T = A.A.A | A.A.A;', + output: 'type T = A.A.A;', + errors: [{ messageId: 'duplicate', line: 1, column: 18 }], + }, + // XXX Test members + { + code: 'type T = {} | {};', + output: 'type T = {};', + errors: [{ messageId: 'duplicate', line: 1, column: 15 }], + }, + { + code: 'type T = { a: string } | { a: string };', + output: 'type T = { a: string };', + errors: [{ messageId: 'duplicate', line: 1, column: 15 }], + }, + { + code: 'type T = { a: string; b: number } | { b: number; a: string };', + output: 'type T = { a: string };', + errors: [{ messageId: 'duplicate', line: 1, column: 15 }], + }, + { + code: 'type T = (() => void) | (() => void);', + output: noFormat`type T = (() => void);`, + errors: [{ messageId: 'duplicate', line: 1, column: 25 }], + }, + { + code: 'type T = (() => void) | (() => void);', + output: noFormat`type T = (() => void);`, + errors: [{ messageId: 'duplicate', line: 1, column: 28 }], + }, + { + code: 'type T = ((a: number) => void) | ((a: number) => void);', + output: noFormat`type T = ((a: number) => void);`, + errors: [{ messageId: 'duplicate', line: 1, column: 34 }], + }, + ], +});