diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index e043549326a..45d6ec7652c 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -200,6 +200,7 @@ In these cases, we create what we call an extension rule; a rule within our plug | [`@typescript-eslint/lines-between-class-members`](./docs/rules/lines-between-class-members.md) | Require or disallow an empty line between class members | | :wrench: | | | [`@typescript-eslint/no-array-constructor`](./docs/rules/no-array-constructor.md) | Disallow generic `Array` constructors | :heavy_check_mark: | :wrench: | | | [`@typescript-eslint/no-dupe-class-members`](./docs/rules/no-dupe-class-members.md) | Disallow duplicate class members | | | | +| [`@typescript-eslint/no-duplicate-imports`](./docs/rules/no-duplicate-imports.md) | Disallow duplicate imports | | | | | [`@typescript-eslint/no-empty-function`](./docs/rules/no-empty-function.md) | Disallow empty functions | :heavy_check_mark: | | | | [`@typescript-eslint/no-extra-parens`](./docs/rules/no-extra-parens.md) | Disallow unnecessary parentheses | | :wrench: | | | [`@typescript-eslint/no-extra-semi`](./docs/rules/no-extra-semi.md) | Disallow unnecessary semicolons | :heavy_check_mark: | :wrench: | | diff --git a/packages/eslint-plugin/docs/rules/no-duplicate-imports.md b/packages/eslint-plugin/docs/rules/no-duplicate-imports.md new file mode 100644 index 00000000000..140a8a1ece1 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-duplicate-imports.md @@ -0,0 +1,22 @@ +# Disallow duplicate imports (`no-duplicate-imports`) + +## Rule Details + +This rule extends the base [`eslint/no-duplicate-imports`](https://eslint.org/docs/rules/no-duplicate-imports) rule. +This version adds support for type-only import and export. + +## How to use + +```jsonc +{ + // note you must disable the base rule as it can report incorrect errors + "no-duplicate-imports": "off", + "@typescript-eslint/no-duplicate-imports": ["error"] +} +``` + +## Options + +See [`eslint/no-duplicate-imports` options](https://eslint.org/docs/rules/no-duplicate-imports#options). + +Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/no-duplicate-imports.md) diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 1de48a81f6e..ec298406498 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -14,6 +14,8 @@ export = { 'brace-style': 'off', '@typescript-eslint/brace-style': 'error', '@typescript-eslint/class-literal-property-style': 'error', + 'comma-dangle': 'off', + '@typescript-eslint/comma-dangle': 'error', 'comma-spacing': 'off', '@typescript-eslint/comma-spacing': 'error', '@typescript-eslint/consistent-indexed-object-style': 'error', @@ -47,6 +49,8 @@ export = { '@typescript-eslint/no-confusing-non-null-assertion': 'error', 'no-dupe-class-members': 'off', '@typescript-eslint/no-dupe-class-members': 'error', + 'no-duplicate-imports': 'off', + '@typescript-eslint/no-duplicate-imports': 'error', '@typescript-eslint/no-dynamic-delete': 'error', 'no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'error', @@ -140,7 +144,5 @@ export = { '@typescript-eslint/typedef': 'error', '@typescript-eslint/unbound-method': 'error', '@typescript-eslint/unified-signatures': 'error', - 'comma-dangle': 'off', - '@typescript-eslint/comma-dangle': 'error', }, }; diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 10d849873a0..587b79d3017 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -106,6 +106,7 @@ import typeAnnotationSpacing from './type-annotation-spacing'; import typedef from './typedef'; import unboundMethod from './unbound-method'; import unifiedSignatures from './unified-signatures'; +import noDuplicateImports from './no-duplicate-imports'; export default { 'adjacent-overload-signatures': adjacentOverloadSignatures, @@ -212,6 +213,7 @@ export default { 'type-annotation-spacing': typeAnnotationSpacing, 'unbound-method': unboundMethod, 'unified-signatures': unifiedSignatures, + 'no-duplicate-imports': noDuplicateImports, indent: indent, quotes: quotes, semi: semi, diff --git a/packages/eslint-plugin/src/rules/no-duplicate-imports.ts b/packages/eslint-plugin/src/rules/no-duplicate-imports.ts new file mode 100644 index 00000000000..d539845746f --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-duplicate-imports.ts @@ -0,0 +1,118 @@ +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import baseRule from 'eslint/lib/rules/no-duplicate-imports'; +import * as util from '../util'; + +type Options = util.InferOptionsTypeFromRule; +type MessageIds = util.InferMessageIdsTypeFromRule; + +export default util.createRule({ + name: 'no-duplicate-imports', + meta: { + type: 'problem', + docs: { + description: 'Disallow duplicate imports', + category: 'Best Practices', + recommended: false, + extendsBaseRule: true, + }, + schema: baseRule.meta.schema, + messages: { + ...baseRule.meta.messages, + importType: '{{module}} type import is duplicated', + importTypeAs: '{{module}} type import is duplicated as type export', + exportType: '{{module}} type export is duplicated', + exportTypeAs: '{{module}} type export is duplicated as type import', + }, + }, + defaultOptions: [ + { + includeExports: false, + }, + ], + create(context, [option]) { + const rules = baseRule.create(context); + const includeExports = option.includeExports; + const typeImports = new Set(); + const typeExports = new Set(); + + function report( + messageId: MessageIds, + node: TSESTree.Node, + module: string, + ): void { + context.report({ + messageId, + node, + data: { + module, + }, + }); + } + + function isStringLiteral( + node: TSESTree.Node | null, + ): node is TSESTree.StringLiteral { + return ( + !!node && + node.type === AST_NODE_TYPES.Literal && + typeof node.value === 'string' + ); + } + + function checkTypeImport(node: TSESTree.ImportDeclaration): void { + if (isStringLiteral(node.source)) { + const value = node.source.value; + if (typeImports.has(value)) { + report('importType', node, value); + } + if (includeExports && typeExports.has(value)) { + report('importTypeAs', node, value); + } + typeImports.add(value); + } + } + + function checkTypeExport( + node: TSESTree.ExportNamedDeclaration | TSESTree.ExportAllDeclaration, + ): void { + if (isStringLiteral(node.source)) { + const value = node.source.value; + if (typeExports.has(value)) { + report('exportType', node, value); + } + if (typeImports.has(value)) { + report('exportTypeAs', node, value); + } + typeExports.add(value); + } + } + + return { + ...rules, + ImportDeclaration(node): void { + if (node.importKind === 'type') { + checkTypeImport(node); + return; + } + rules.ImportDeclaration(node); + }, + ExportNamedDeclaration(node): void { + if (includeExports && node.exportKind === 'type') { + checkTypeExport(node); + return; + } + rules.ExportNamedDeclaration?.(node); + }, + ExportAllDeclaration(node): void { + if (includeExports && node.exportKind === 'type') { + checkTypeExport(node); + return; + } + rules.ExportAllDeclaration?.(node); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-imports.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-imports.test.ts new file mode 100644 index 00000000000..d133a7aadd9 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-duplicate-imports.test.ts @@ -0,0 +1,153 @@ +import rule from '../../src/rules/no-duplicate-imports'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('no-dupe-class-members', rule, { + valid: [ + { + code: "import type foo from 'foo';", + }, + { + code: "import type { foo } from 'foo';", + }, + { + code: ` + import foo from 'foo'; + import type bar from 'foo'; + `, + }, + { + code: ` + import { foo } from 'foo'; + import type { bar } from 'foo'; + `, + }, + { + code: ` + import type { foo } from 'foo'; + export type foo = foo; + `, + }, + { + code: ` + import type { foo } from 'foo'; + export type { foo }; + `, + }, + { + code: ` + export { foo } from 'foo'; + export type { foo } from 'foo'; + `, + }, + { + code: ` + export type * as foo from 'foo'; + export type * as bar from 'foo'; + `, + }, + { + code: ` + import type { bar } from 'foo'; + export type { foo } from 'foo'; + `, + }, + { + code: ` + import type { foo } from 'foo'; + export type { bar } from 'bar'; + `, + options: [{ includeExports: true }], + }, + { + code: ` + import type { foo } from 'foo'; + export type { bar }; + `, + options: [{ includeExports: true }], + }, + ], + invalid: [ + { + code: ` + import type foo from 'foo'; + import type bar from 'foo'; + `, + errors: [ + { + messageId: 'importType', + data: { + module: 'foo', + }, + }, + ], + }, + { + code: ` + import type { foo } from 'foo'; + import type { bar } from 'foo'; + `, + errors: [{ messageId: 'importType' }], + }, + { + code: ` + export type { foo } from 'foo'; + import type { bar } from 'foo'; + `, + options: [{ includeExports: true }], + errors: [{ messageId: 'importTypeAs' }], + }, + { + code: ` + import type foo from 'foo'; + export type * from 'foo'; + `, + options: [{ includeExports: true }], + errors: [{ messageId: 'exportTypeAs' }], + }, + { + code: ` + import type { foo } from 'foo'; + export type { foo } from 'foo'; + `, + options: [{ includeExports: true }], + errors: [{ messageId: 'exportTypeAs' }], + }, + { + code: ` + export type * as foo from 'foo'; + export type * as bar from 'foo'; + `, + options: [{ includeExports: true }], + errors: [{ messageId: 'exportType' }], + }, + + // check base rule + { + code: ` + import foo from 'foo'; + import bar from 'foo'; + `, + errors: [{ messageId: 'import' }], + }, + { + code: ` + import foo from 'foo'; + export * from 'foo'; + `, + options: [{ includeExports: true }], + errors: [{ messageId: 'exportAs' }], + }, + { + code: ` + import foo from 'foo'; + export { foo } from 'foo'; + `, + options: [{ includeExports: true }], + errors: [{ messageId: 'exportAs' }], + }, + ], +}); diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts index 39d49db7328..d11c5589b38 100644 --- a/packages/eslint-plugin/typings/eslint-rules.d.ts +++ b/packages/eslint-plugin/typings/eslint-rules.d.ts @@ -762,3 +762,29 @@ declare module 'eslint/lib/rules/comma-dangle' { >; export = rule; } + +declare module 'eslint/lib/rules/no-duplicate-imports' { + import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + + const rule: TSESLint.RuleModule< + | 'import' + | 'importAs' + | 'export' + | 'exportAs' + | 'importType' + | 'importTypeAs' + | 'exportType' + | 'exportTypeAs', + [ + { + includeExports?: boolean; + }, + ], + { + ImportDeclaration(node: TSESTree.ImportDeclaration): void; + ExportNamedDeclaration?(node: TSESTree.ExportNamedDeclaration): void; + ExportAllDeclaration?(node: TSESTree.ExportAllDeclaration): void; + } + >; + export = rule; +}