diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 8fac3dcde07..37a27ca9511 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -188,6 +188,7 @@ In these cases, we create what we call an extension rule; a rule within our plug | Name | Description | :heavy_check_mark: | :wrench: | :thought_balloon: | | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------ | -------- | ----------------- | | [`@typescript-eslint/brace-style`](./docs/rules/brace-style.md) | Enforce consistent brace style for blocks | | :wrench: | | +| [`@typescript-eslint/comma-dangle`](./docs/rules/comma-dangle.md) | Require or disallow trailing comma | | :wrench: | | | [`@typescript-eslint/comma-spacing`](./docs/rules/comma-spacing.md) | Enforces consistent spacing before and after commas | | :wrench: | | | [`@typescript-eslint/default-param-last`](./docs/rules/default-param-last.md) | Enforce default parameters to be last | | | | | [`@typescript-eslint/dot-notation`](./docs/rules/dot-notation.md) | enforce dot notation whenever possible | | :wrench: | :thought_balloon: | diff --git a/packages/eslint-plugin/docs/rules/comma-dangle.md b/packages/eslint-plugin/docs/rules/comma-dangle.md new file mode 100644 index 00000000000..bfb40d338bc --- /dev/null +++ b/packages/eslint-plugin/docs/rules/comma-dangle.md @@ -0,0 +1,34 @@ +# Require or disallow trailing comma (`comma-dangle`) + +## Rule Details + +This rule extends the base [`eslint/comma-dangle`](https://eslint.org/docs/rules/comma-dangle) rule. +It adds support for TypeScript syntax. + +See the [ESLint documentation](https://eslint.org/docs/rules/comma-dangle) for more details on the `comma-dangle` rule. + +## Rule Changes + +```cjson +{ + // note you must disable the base rule as it can report incorrect errors + "comma-dangle": "off", + "@typescript-eslint/comma-dangle": ["error"] +} +``` + +In addition to the options supported by the `comma-dangle` rule in ESLint core, the rule adds the following options: + +## Options + +This rule has a string option and an object option. + +- Object option: + + - `"enums"` is for trailing comma in enum. (e.g. `enum Foo = {Bar,}`) + - `"generics"` is for trailing comma in generic. (e.g. `function foo() {}`) + - `"tuples"` is for trailing comma in tuple. (e.g. `type Foo = [string,]`) + +- [See the other options allowed](https://github.com/eslint/eslint/blob/master/docs/rules/comma-dangle.md#options) + +Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/comma-dangle.md) diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 7e457d7aa1f..84b4e93fc90 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -139,5 +139,7 @@ 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/comma-dangle.ts b/packages/eslint-plugin/src/rules/comma-dangle.ts new file mode 100644 index 00000000000..e0e06a6711d --- /dev/null +++ b/packages/eslint-plugin/src/rules/comma-dangle.ts @@ -0,0 +1,179 @@ +import * as util from '../util'; +import baseRule from 'eslint/lib/rules/comma-dangle'; +import { + TSESTree, + AST_NODE_TYPES, +} from '@typescript-eslint/experimental-utils'; + +export type Options = util.InferOptionsTypeFromRule; +export type MessageIds = util.InferMessageIdsTypeFromRule; + +type Option = Options[0]; +type NormalizedOptions = Required< + Pick, 'enums' | 'generics' | 'tuples'> +>; + +const OPTION_VALUE_SCHEME = [ + 'always-multiline', + 'always', + 'never', + 'only-multiline', +]; + +const DEFAULT_OPTION_VALUE = 'never'; + +function normalizeOptions(options: Option): NormalizedOptions { + if (typeof options === 'string') { + return { + enums: options, + generics: options, + tuples: options, + }; + } + return { + enums: options.enums ?? DEFAULT_OPTION_VALUE, + generics: options.generics ?? DEFAULT_OPTION_VALUE, + tuples: options.tuples ?? DEFAULT_OPTION_VALUE, + }; +} + +export default util.createRule({ + name: 'comma-dangle', + meta: { + type: 'layout', + docs: { + description: 'Require or disallow trailing comma', + category: 'Stylistic Issues', + recommended: false, + extendsBaseRule: true, + }, + schema: { + definitions: { + value: { + enum: OPTION_VALUE_SCHEME, + }, + valueWithIgnore: { + enum: [...OPTION_VALUE_SCHEME, 'ignore'], + }, + }, + type: 'array', + items: [ + { + oneOf: [ + { + $ref: '#/definitions/value', + }, + { + type: 'object', + properties: { + arrays: { $ref: '#/definitions/valueWithIgnore' }, + objects: { $ref: '#/definitions/valueWithIgnore' }, + imports: { $ref: '#/definitions/valueWithIgnore' }, + exports: { $ref: '#/definitions/valueWithIgnore' }, + functions: { $ref: '#/definitions/valueWithIgnore' }, + enums: { $ref: '#/definitions/valueWithIgnore' }, + generics: { $ref: '#/definitions/valueWithIgnore' }, + tuples: { $ref: '#/definitions/valueWithIgnore' }, + }, + additionalProperties: false, + }, + ], + }, + ], + }, + fixable: 'code', + messages: baseRule.meta.messages, + }, + defaultOptions: ['never'], + create(context, [options]) { + const rules = baseRule.create(context); + const sourceCode = context.getSourceCode(); + const normalizedOptions = normalizeOptions(options); + + const predicate = { + always: forceComma, + 'always-multiline': forceCommaIfMultiline, + 'only-multiline': allowCommaIfMultiline, + never: forbidComma, + ignore: (): void => {}, + }; + + function last(nodes: TSESTree.Node[]): TSESTree.Node | null { + return nodes[nodes.length - 1] ?? null; + } + + function getLastItem(node: TSESTree.Node): TSESTree.Node | null { + switch (node.type) { + case AST_NODE_TYPES.TSEnumDeclaration: + return last(node.members); + case AST_NODE_TYPES.TSTypeParameterDeclaration: + return last(node.params); + case AST_NODE_TYPES.TSTupleType: + return last(node.elementTypes); + default: + return null; + } + } + + function getTrailingToken(node: TSESTree.Node): TSESTree.Token | null { + const last = getLastItem(node); + const trailing = last && sourceCode.getTokenAfter(last); + return trailing; + } + + function isMultiline(node: TSESTree.Node): boolean { + const last = getLastItem(node); + const lastToken = sourceCode.getLastToken(node); + return last?.loc.end.line !== lastToken?.loc.end.line; + } + + function forbidComma(node: TSESTree.Node): void { + const last = getLastItem(node); + const trailing = getTrailingToken(node); + if (last && trailing && util.isCommaToken(trailing)) { + context.report({ + node, + messageId: 'unexpected', + fix(fixer) { + return fixer.remove(trailing); + }, + }); + } + } + + function forceComma(node: TSESTree.Node): void { + const last = getLastItem(node); + const trailing = getTrailingToken(node); + if (last && trailing && !util.isCommaToken(trailing)) { + context.report({ + node, + messageId: 'missing', + fix(fixer) { + return fixer.insertTextAfter(last, ','); + }, + }); + } + } + + function allowCommaIfMultiline(node: TSESTree.Node): void { + if (!isMultiline(node)) { + forbidComma(node); + } + } + + function forceCommaIfMultiline(node: TSESTree.Node): void { + if (isMultiline(node)) { + forceComma(node); + } else { + forbidComma(node); + } + } + + return { + ...rules, + TSEnumDeclaration: predicate[normalizedOptions.enums], + TSTypeParameterDeclaration: predicate[normalizedOptions.generics], + TSTupleType: predicate[normalizedOptions.tuples], + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index fa8dba93ed1..8b5049e2502 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -6,6 +6,7 @@ import banTslintComment from './ban-tslint-comment'; import banTypes from './ban-types'; import braceStyle from './brace-style'; import classLiteralPropertyStyle from './class-literal-property-style'; +import commaDangle from './comma-dangle'; import commaSpacing from './comma-spacing'; import confusingNonNullAssertionLikeNotEqual from './no-confusing-non-null-assertion'; import consistentTypeAssertions from './consistent-type-assertions'; @@ -114,6 +115,7 @@ export default { 'ban-types': banTypes, 'brace-style': braceStyle, 'class-literal-property-style': classLiteralPropertyStyle, + 'comma-dangle': commaDangle, 'comma-spacing': commaSpacing, 'consistent-type-assertions': consistentTypeAssertions, 'consistent-type-definitions': consistentTypeDefinitions, diff --git a/packages/eslint-plugin/tests/rules/comma-dangle.test.ts b/packages/eslint-plugin/tests/rules/comma-dangle.test.ts new file mode 100644 index 00000000000..1e94e982971 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/comma-dangle.test.ts @@ -0,0 +1,255 @@ +/* eslint-disable eslint-comments/no-use */ +// this rule tests the new lines, which prettier will want to fix and break the tests +/* eslint "@typescript-eslint/internal/plugin-test-formatting": ["error", { formatWithPrettier: false }] */ +/* eslint-enable eslint-comments/no-use */ +import rule from '../../src/rules/comma-dangle'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('comma-dangle', rule, { + valid: [ + // default + { code: 'enum Foo {}' }, + { code: 'enum Foo {\n}' }, + { code: 'enum Foo {Bar}' }, + { code: 'function Foo() {}' }, + { code: 'type Foo = []' }, + { code: 'type Foo = [\n]' }, + + // never + { code: 'enum Foo {Bar}', options: ['never'] }, + { code: 'enum Foo {Bar\n}', options: ['never'] }, + { code: 'enum Foo {Bar\n}', options: [{ enums: 'never' }] }, + { code: 'function Foo() {}', options: ['never'] }, + { code: 'function Foo() {}', options: ['never'] }, + { code: 'function Foo() {}', options: [{ generics: 'never' }] }, + { code: 'type Foo = [string]', options: ['never'] }, + { code: 'type Foo = [string]', options: [{ tuples: 'never' }] }, + + // always + { code: 'enum Foo {Bar,}', options: ['always'] }, + { code: 'enum Foo {Bar,\n}', options: ['always'] }, + { code: 'enum Foo {Bar,\n}', options: [{ enums: 'always' }] }, + { code: 'function Foo() {}', options: ['always'] }, + { code: 'function Foo() {}', options: ['always'] }, + { code: 'function Foo() {}', options: [{ generics: 'always' }] }, + { code: 'type Foo = [string,]', options: ['always'] }, + { code: 'type Foo = [string,\n]', options: [{ tuples: 'always' }] }, + + // always-multiline + { code: 'enum Foo {Bar}', options: ['always-multiline'] }, + { code: 'enum Foo {Bar,\n}', options: ['always-multiline'] }, + { code: 'enum Foo {Bar,\n}', options: [{ enums: 'always-multiline' }] }, + { code: 'function Foo() {}', options: ['always-multiline'] }, + { code: 'function Foo() {}', options: ['always-multiline'] }, + { + code: 'function Foo() {}', + options: [{ generics: 'always-multiline' }], + }, + { code: 'type Foo = [string]', options: ['always-multiline'] }, + { code: 'type Foo = [string,\n]', options: ['always-multiline'] }, + { + code: 'type Foo = [string,\n]', + options: [{ tuples: 'always-multiline' }], + }, + + // only-multiline + { code: 'enum Foo {Bar}', options: ['only-multiline'] }, + { code: 'enum Foo {Bar\n}', options: ['only-multiline'] }, + { code: 'enum Foo {Bar,\n}', options: ['only-multiline'] }, + { code: 'enum Foo {Bar,\n}', options: [{ enums: 'only-multiline' }] }, + { code: 'function Foo() {}', options: ['only-multiline'] }, + { code: 'function Foo() {}', options: ['only-multiline'] }, + { code: 'function Foo() {}', options: ['only-multiline'] }, + { + code: 'function Foo() {}', + options: [{ generics: 'only-multiline' }], + }, + { + code: 'function Foo() {}', + options: [{ generics: 'only-multiline' }], + }, + { code: 'type Foo = [string\n]', options: [{ tuples: 'only-multiline' }] }, + { code: 'type Foo = [string,\n]', options: [{ tuples: 'only-multiline' }] }, + + // each options + { + code: ` +const Obj = { a: 1 }; +enum Foo {Bar} +function Baz() {} +type Qux = [string, +] + `, + options: [ + { + enums: 'never', + generics: 'always', + tuples: 'always-multiline', + }, + ], + }, + ], + invalid: [ + // base rule + { + code: 'const Foo = {bar: 1,}', + output: 'const Foo = {bar: 1}', + errors: [{ messageId: 'unexpected' }], + }, + + // default + { + code: 'enum Foo {Bar,}', + output: 'enum Foo {Bar}', + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'function Foo() {}', + output: 'function Foo() {}', + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'type Foo = [string,]', + output: 'type Foo = [string]', + errors: [{ messageId: 'unexpected' }], + }, + + // never + { + code: 'enum Foo {Bar,}', + output: 'enum Foo {Bar}', + options: ['never'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'enum Foo {Bar,\n}', + output: 'enum Foo {Bar\n}', + options: ['never'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'function Foo() {}', + output: 'function Foo() {}', + options: ['never'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'function Foo() {}', + output: 'function Foo() {}', + options: ['never'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'type Foo = [string,]', + output: 'type Foo = [string]', + options: ['never'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'type Foo = [string,\n]', + output: 'type Foo = [string\n]', + options: ['never'], + errors: [{ messageId: 'unexpected' }], + }, + + // always + { + code: 'enum Foo {Bar}', + output: 'enum Foo {Bar,}', + options: ['always'], + errors: [{ messageId: 'missing' }], + }, + { + code: 'enum Foo {Bar\n}', + output: 'enum Foo {Bar,\n}', + options: ['always'], + errors: [{ messageId: 'missing' }], + }, + { + code: 'function Foo() {}', + output: 'function Foo() {}', + options: ['always'], + errors: [{ messageId: 'missing' }], + }, + { + code: 'function Foo() {}', + output: 'function Foo() {}', + options: ['always'], + errors: [{ messageId: 'missing' }], + }, + { + code: 'type Foo = [string]', + output: 'type Foo = [string,]', + options: ['always'], + errors: [{ messageId: 'missing' }], + }, + { + code: 'type Foo = [string\n]', + output: 'type Foo = [string,\n]', + options: ['always'], + errors: [{ messageId: 'missing' }], + }, + + // always-multiline + { + code: 'enum Foo {Bar,}', + output: 'enum Foo {Bar}', + options: ['always-multiline'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'enum Foo {Bar\n}', + output: 'enum Foo {Bar,\n}', + options: ['always-multiline'], + errors: [{ messageId: 'missing' }], + }, + { + code: 'function Foo() {}', + output: 'function Foo() {}', + options: ['always-multiline'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'function Foo() {}', + output: 'function Foo() {}', + options: ['always-multiline'], + errors: [{ messageId: 'missing' }], + }, + { + code: 'type Foo = [string,]', + output: 'type Foo = [string]', + options: ['always-multiline'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'type Foo = [string\n]', + output: 'type Foo = [string,\n]', + options: ['always-multiline'], + errors: [{ messageId: 'missing' }], + }, + + // only-multiline + { + code: 'enum Foo {Bar,}', + output: 'enum Foo {Bar}', + options: ['only-multiline'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'function Foo() {}', + output: 'function Foo() {}', + options: ['only-multiline'], + errors: [{ messageId: 'unexpected' }], + }, + { + code: 'type Foo = [string,]', + output: 'type Foo = [string]', + options: ['only-multiline'], + errors: [{ messageId: 'unexpected' }], + }, + ], +}); diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts index 7cabdf8a3c9..39d49db7328 100644 --- a/packages/eslint-plugin/typings/eslint-rules.d.ts +++ b/packages/eslint-plugin/typings/eslint-rules.d.ts @@ -729,3 +729,36 @@ declare module 'eslint/lib/rules/no-loss-of-precision' { >; export = rule; } + +declare module 'eslint/lib/rules/comma-dangle' { + import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + + type StringOptions = + | 'always-multiline' + | 'always' + | 'never' + | 'only-multiline'; + type Selectors = + | 'arrays' + | 'objects' + | 'imports' + | 'exports' + | 'functions' + | 'enums' + | 'generics' + | 'tuples'; + type ObjectOptions = Partial>; + + const rule: TSESLint.RuleModule< + 'unexpected' | 'missing', + [StringOptions | ObjectOptions], + { + TSEnumDeclaration(node: TSESTree.TSEnumDeclaration): void; + TSTypeParameterDeclaration( + node: TSESTree.TSTypeParameterDeclaration, + ): void; + TSTupleType(node: TSESTree.TSTupleType): void; + } + >; + export = rule; +}