Skip to content

Commit

Permalink
feat(eslint-plugin): add extension rule keyword-spacing (#1739)
Browse files Browse the repository at this point in the history
  • Loading branch information
OoDeLally committed Apr 20, 2020
1 parent 369978e commit c5106dd
Show file tree
Hide file tree
Showing 7 changed files with 312 additions and 1 deletion.
1 change: 1 addition & 0 deletions packages/eslint-plugin/README.md
Expand Up @@ -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: | | |
Expand Down
22 changes: 22 additions & 0 deletions 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).

<sup>Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/keyword-spacing.md)</sup>
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/configs/all.json
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/eslint-plugin/src/rules/index.ts
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions 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<typeof baseRule>;
export type MessageIds = util.InferMessageIdsTypeFromRule<typeof baseRule>;

export default util.createRule<Options, MessageIds>({
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;
},
};
},
});
153 changes: 153 additions & 0 deletions 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<MessageIds>[] {
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<MessageIds>[] {
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<MessageIds>[] {
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<MessageIds>[] {
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'),
},
],
});
79 changes: 79 additions & 0 deletions packages/eslint-plugin/typings/eslint-rules.d.ts
Expand Up @@ -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<TSESTree.DebuggerStatement>;
WithStatement: RuleFunction<TSESTree.WithStatement>;

// Statements - Control flow
BreakStatement: RuleFunction<TSESTree.BreakStatement>;
ContinueStatement: RuleFunction<TSESTree.ContinueStatement>;
ReturnStatement: RuleFunction<TSESTree.ReturnStatement>;
ThrowStatement: RuleFunction<TSESTree.ThrowStatement>;
TryStatement: RuleFunction<TSESTree.TryStatement>;

// Statements - Choice
IfStatement: RuleFunction<TSESTree.IfStatement>;
SwitchStatement: RuleFunction<TSESTree.Node>;
SwitchCase: RuleFunction<TSESTree.Node>;

// Statements - Loops
DoWhileStatement: RuleFunction<TSESTree.DoWhileStatement>;
ForInStatement: RuleFunction<TSESTree.ForInStatement>;
ForOfStatement: RuleFunction<TSESTree.ForOfStatement>;
ForStatement: RuleFunction<TSESTree.ForStatement>;
WhileStatement: RuleFunction<TSESTree.WhileStatement>;

// Statements - Declarations
ClassDeclaration: RuleFunction<TSESTree.ClassDeclaration>;
ExportNamedDeclaration: RuleFunction<TSESTree.ExportNamedDeclaration>;
ExportDefaultDeclaration: RuleFunction<TSESTree.ExportDefaultDeclaration>;
ExportAllDeclaration: RuleFunction<TSESTree.ExportAllDeclaration>;
FunctionDeclaration: RuleFunction<TSESTree.FunctionDeclaration>;
ImportDeclaration: RuleFunction<TSESTree.ImportDeclaration>;
VariableDeclaration: RuleFunction<TSESTree.VariableDeclaration>;

// Expressions
ArrowFunctionExpression: RuleFunction<TSESTree.ArrowFunctionExpression>;
AwaitExpression: RuleFunction<TSESTree.AwaitExpression>;
ClassExpression: RuleFunction<TSESTree.ClassExpression>;
FunctionExpression: RuleFunction<TSESTree.FunctionExpression>;
NewExpression: RuleFunction<TSESTree.NewExpression>;
Super: RuleFunction<TSESTree.Super>;
ThisExpression: RuleFunction<TSESTree.ThisExpression>;
UnaryExpression: RuleFunction<TSESTree.UnaryExpression>;
YieldExpression: RuleFunction<TSESTree.YieldExpression>;

// Others
ImportNamespaceSpecifier: RuleFunction<TSESTree.ImportNamespaceSpecifier>;
MethodDefinition: RuleFunction<TSESTree.MethodDefinition>;
Property: RuleFunction<TSESTree.Property>;
}
>;
export = rule;
}

declare module 'eslint/lib/rules/no-dupe-class-members' {
import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';

Expand Down

0 comments on commit c5106dd

Please sign in to comment.