From c5106dd4bf2bc8846cc39aa8bb50c33bec026d4d Mon Sep 17 00:00:00 2001 From: Pascal Heitz Date: Mon, 20 Apr 2020 07:42:48 +0200 Subject: [PATCH] feat(eslint-plugin): add extension rule `keyword-spacing` (#1739) --- packages/eslint-plugin/README.md | 1 + .../docs/rules/keyword-spacing.md | 22 +++ packages/eslint-plugin/src/configs/all.json | 2 + packages/eslint-plugin/src/rules/index.ts | 4 +- .../src/rules/keyword-spacing.ts | 52 ++++++ .../tests/rules/keyword-spacing.test.ts | 153 ++++++++++++++++++ .../eslint-plugin/typings/eslint-rules.d.ts | 79 +++++++++ 7 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 packages/eslint-plugin/docs/rules/keyword-spacing.md create mode 100644 packages/eslint-plugin/src/rules/keyword-spacing.ts create mode 100644 packages/eslint-plugin/tests/rules/keyword-spacing.test.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 7ed22981926..0428cff43fc 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -183,6 +183,7 @@ In these cases, we create what we call an extension rule; a rule within our plug | [`@typescript-eslint/default-param-last`](./docs/rules/default-param-last.md) | Enforce default parameters to be last | | | | | [`@typescript-eslint/func-call-spacing`](./docs/rules/func-call-spacing.md) | Require or disallow spacing between function identifiers and their invocations | | :wrench: | | | [`@typescript-eslint/indent`](./docs/rules/indent.md) | Enforce consistent indentation | | :wrench: | | +| [`@typescript-eslint/keyword-spacing`](./docs/rules/keyword-spacing.md) | Enforce consistent spacing before and after keywords | | :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-empty-function`](./docs/rules/no-empty-function.md) | Disallow empty functions | :heavy_check_mark: | | | diff --git a/packages/eslint-plugin/docs/rules/keyword-spacing.md b/packages/eslint-plugin/docs/rules/keyword-spacing.md new file mode 100644 index 00000000000..ca2926d6c82 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/keyword-spacing.md @@ -0,0 +1,22 @@ +# Enforce consistent spacing before and after keywords (`keyword-spacing`) + +## Rule Details + +This rule extends the base [`eslint/keyword-spacing`](https://eslint.org/docs/rules/keyword-spacing) rule. +This version adds support for generic type parameters on function calls. + +## How to use + +```cjson +{ + // note you must disable the base rule as it can report incorrect errors + "keyword-spacing": "off", + "@typescript-eslint/keyword-spacing": ["error"] +} +``` + +## Options + +See [`eslint/keyword-spacing` options](https://eslint.org/docs/rules/keyword-spacing#options). + +Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/keyword-spacing.md) diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json index c736840a3ff..ec65f143c59 100644 --- a/packages/eslint-plugin/src/configs/all.json +++ b/packages/eslint-plugin/src/configs/all.json @@ -22,6 +22,8 @@ "@typescript-eslint/func-call-spacing": "error", "indent": "off", "@typescript-eslint/indent": "error", + "keyword-spacing": "off", + "@typescript-eslint/keyword-spacing": "error", "@typescript-eslint/member-delimiter-style": "error", "@typescript-eslint/member-ordering": "error", "@typescript-eslint/method-signature-style": "error", diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 901d31a8b9f..63c402297e3 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -19,6 +19,7 @@ import funcCallSpacing from './func-call-spacing'; import genericTypeNaming from './generic-type-naming'; import indent from './indent'; import interfaceNamePrefix from './interface-name-prefix'; +import keywordSpacing from './keyword-spacing'; import memberDelimiterStyle from './member-delimiter-style'; import memberNaming from './member-naming'; import memberOrdering from './member-ordering'; @@ -31,10 +32,10 @@ import noDynamicDelete from './no-dynamic-delete'; import noEmptyFunction from './no-empty-function'; import noEmptyInterface from './no-empty-interface'; import noExplicitAny from './no-explicit-any'; +import noExtraneousClass from './no-extraneous-class'; import noExtraNonNullAssertion from './no-extra-non-null-assertion'; import noExtraParens from './no-extra-parens'; import noExtraSemi from './no-extra-semi'; -import noExtraneousClass from './no-extraneous-class'; import noFloatingPromises from './no-floating-promises'; import noForInArray from './no-for-in-array'; import noImpliedEval from './no-implied-eval'; @@ -118,6 +119,7 @@ export default { 'generic-type-naming': genericTypeNaming, indent: indent, 'interface-name-prefix': interfaceNamePrefix, + 'keyword-spacing': keywordSpacing, 'member-delimiter-style': memberDelimiterStyle, 'member-naming': memberNaming, 'member-ordering': memberOrdering, diff --git a/packages/eslint-plugin/src/rules/keyword-spacing.ts b/packages/eslint-plugin/src/rules/keyword-spacing.ts new file mode 100644 index 00000000000..8c4ff1d38ff --- /dev/null +++ b/packages/eslint-plugin/src/rules/keyword-spacing.ts @@ -0,0 +1,52 @@ +import { AST_TOKEN_TYPES } from '@typescript-eslint/experimental-utils'; +import baseRule from 'eslint/lib/rules/keyword-spacing'; +import * as util from '../util'; + +export type Options = util.InferOptionsTypeFromRule; +export type MessageIds = util.InferMessageIdsTypeFromRule; + +export default util.createRule({ + name: 'keyword-spacing', + meta: { + type: 'layout', + docs: { + description: 'Enforce consistent spacing before and after keywords', + category: 'Stylistic Issues', + recommended: false, + extendsBaseRule: true, + }, + fixable: 'whitespace', + schema: baseRule.meta.schema, + messages: baseRule.meta.messages, + }, + defaultOptions: [{}], + + create(context) { + const sourceCode = context.getSourceCode(); + const baseRules = baseRule.create(context); + return { + ...baseRules, + TSAsExpression(node): void { + const asToken = util.nullThrows( + sourceCode.getTokenAfter( + node.expression, + token => token.value === 'as', + ), + util.NullThrowsReasons.MissingToken('as', node.type), + ); + const oldTokenType = asToken.type; + // as is a contextual keyword, so it's always reported as an Identifier + // the rule looks for keyword tokens, so we temporarily override it + // we mutate it at the token level because the rule calls sourceCode.getFirstToken, + // so mutating a copy would not change the underlying copy returned by that method + asToken.type = AST_TOKEN_TYPES.Keyword; + + // use this selector just because it is just a call to `checkSpacingAroundFirstToken` + baseRules.DebuggerStatement(asToken as never); + + // make sure to reset the type afterward so we don't permanently mutate the AST + asToken.type = oldTokenType; + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/keyword-spacing.test.ts b/packages/eslint-plugin/tests/rules/keyword-spacing.test.ts new file mode 100644 index 00000000000..987caebb1e4 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/keyword-spacing.test.ts @@ -0,0 +1,153 @@ +/* eslint-disable eslint-comments/no-use */ +// this rule tests the spacing, 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 { TSESLint } from '@typescript-eslint/experimental-utils'; +import rule, { MessageIds, Options } from '../../src/rules/keyword-spacing'; +import { RuleTester } from '../RuleTester'; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +const BOTH = { before: true, after: true }; +const NEITHER = { before: false, after: false }; + +/** + * Creates an option object to test an 'overrides' option. + * + * e.g. + * + * override('as', BOTH) + * + * returns + * + * { + * before: false, + * after: false, + * overrides: {as: {before: true, after: true}} + * } + * @param keyword A keyword to be overridden. + * @param value A value to override. + * @returns An option object to test an 'overrides' option. + */ +function overrides(keyword: string, value: Options[0]): Options[0] { + return { + before: value.before === false, + after: value.after === false, + overrides: { [keyword]: value }, + }; +} + +/** + * Gets an error message that expected space(s) before a specified keyword. + * @param keyword A keyword. + * @returns An error message. + */ +function expectedBefore(keyword: string): TSESLint.TestCaseError[] { + return [{ messageId: 'expectedBefore', data: { value: keyword } }]; +} + +/** + * Gets an error message that expected space(s) after a specified keyword. + * @param keyword A keyword. + * @returns An error message. + */ +function expectedAfter(keyword: string): TSESLint.TestCaseError[] { + return [{ messageId: 'expectedAfter', data: { value: keyword } }]; +} + +/** + * Gets an error message that unexpected space(s) before a specified keyword. + * @param keyword A keyword. + * @returns An error message. + */ +function unexpectedBefore( + keyword: string, +): TSESLint.TestCaseError[] { + return [{ messageId: 'unexpectedBefore', data: { value: keyword } }]; +} + +/** + * Gets an error message that unexpected space(s) after a specified keyword. + * @param keyword A keyword. + * @returns An error message. + */ +function unexpectedAfter( + keyword: string, +): TSESLint.TestCaseError[] { + return [{ messageId: 'unexpectedAfter', data: { value: keyword } }]; +} + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('keyword-spacing', rule, { + valid: [ + //---------------------------------------------------------------------- + // as (typing) + //---------------------------------------------------------------------- + { + code: 'const foo = {} as {};', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + }, + { + code: 'const foo = {}as{};', + options: [NEITHER], + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + }, + { + code: 'const foo = {} as {};', + options: [overrides('as', BOTH)], + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + }, + { + code: 'const foo = {}as{};', + options: [overrides('as', NEITHER)], + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + }, + { + code: 'const foo = {} as {};', + options: [{ overrides: { as: {} } }], + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + }, + ], + invalid: [ + //---------------------------------------------------------------------- + // as (typing) + //---------------------------------------------------------------------- + { + code: 'const foo = {}as {};', + output: 'const foo = {} as {};', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: expectedBefore('as'), + }, + { + code: 'const foo = {} as{};', + output: 'const foo = {}as{};', + options: [NEITHER], + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: unexpectedBefore('as'), + }, + { + code: 'const foo = {} as{};', + output: 'const foo = {} as {};', + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: expectedAfter('as'), + }, + { + code: 'const foo = {}as {};', + output: 'const foo = {}as{};', + options: [NEITHER], + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: unexpectedAfter('as'), + }, + { + code: 'const foo = {} as{};', + options: [{ overrides: { as: {} } }], + parserOptions: { ecmaVersion: 6, sourceType: 'module' }, + errors: expectedAfter('as'), + }, + ], +}); diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts index ea60d9b3169..ac8700d42e5 100644 --- a/packages/eslint-plugin/typings/eslint-rules.d.ts +++ b/packages/eslint-plugin/typings/eslint-rules.d.ts @@ -142,6 +142,85 @@ declare module 'eslint/lib/rules/indent' { export = rule; } +declare module 'eslint/lib/rules/keyword-spacing' { + import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + import { RuleFunction } from '@typescript-eslint/experimental-utils/dist/ts-eslint'; + + type Options = [ + { + before?: boolean; + after?: boolean; + overrides?: Record< + string, + { + before?: boolean; + after?: boolean; + } + >; + }, + ]; + type MessageIds = + | 'expectedBefore' + | 'expectedAfter' + | 'unexpectedBefore' + | 'unexpectedAfter'; + + const rule: TSESLint.RuleModule< + MessageIds, + Options, + { + // Statements + DebuggerStatement: RuleFunction; + WithStatement: RuleFunction; + + // Statements - Control flow + BreakStatement: RuleFunction; + ContinueStatement: RuleFunction; + ReturnStatement: RuleFunction; + ThrowStatement: RuleFunction; + TryStatement: RuleFunction; + + // Statements - Choice + IfStatement: RuleFunction; + SwitchStatement: RuleFunction; + SwitchCase: RuleFunction; + + // Statements - Loops + DoWhileStatement: RuleFunction; + ForInStatement: RuleFunction; + ForOfStatement: RuleFunction; + ForStatement: RuleFunction; + WhileStatement: RuleFunction; + + // Statements - Declarations + ClassDeclaration: RuleFunction; + ExportNamedDeclaration: RuleFunction; + ExportDefaultDeclaration: RuleFunction; + ExportAllDeclaration: RuleFunction; + FunctionDeclaration: RuleFunction; + ImportDeclaration: RuleFunction; + VariableDeclaration: RuleFunction; + + // Expressions + ArrowFunctionExpression: RuleFunction; + AwaitExpression: RuleFunction; + ClassExpression: RuleFunction; + FunctionExpression: RuleFunction; + NewExpression: RuleFunction; + Super: RuleFunction; + ThisExpression: RuleFunction; + UnaryExpression: RuleFunction; + YieldExpression: RuleFunction; + + // Others + ImportNamespaceSpecifier: RuleFunction; + MethodDefinition: RuleFunction; + Property: RuleFunction; + } + >; + export = rule; +} + declare module 'eslint/lib/rules/no-dupe-class-members' { import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';