From 92e65ec41033df9375ecb5b5c4ebf61c4ef7e73d Mon Sep 17 00:00:00 2001 From: Brad Zacher Date: Wed, 24 Apr 2019 05:59:31 -0700 Subject: [PATCH] feat(eslint-plugin): Add func-call-spacing (#448) --- packages/eslint-plugin/README.md | 3 +- .../docs/rules/func-call-spacing.md | 26 ++ .../src/rules/func-call-spacing.ts | 153 ++++++++ packages/eslint-plugin/src/util/astUtils.ts | 1 + packages/eslint-plugin/src/util/index.ts | 1 + .../tests/rules/func-call-spacing.test.ts | 367 ++++++++++++++++++ packages/eslint-plugin/typings/ts-eslint.d.ts | 2 +- 7 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 packages/eslint-plugin/docs/rules/func-call-spacing.md create mode 100644 packages/eslint-plugin/src/rules/func-call-spacing.ts create mode 100644 packages/eslint-plugin/src/util/astUtils.ts create mode 100644 packages/eslint-plugin/tests/rules/func-call-spacing.test.ts diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index e361ab2f3a4..f02d9887bca 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -120,9 +120,10 @@ Then you should add `airbnb` (or `airbnb-base`) to your `extends` section of `.e | [`@typescript-eslint/explicit-function-return-type`](./docs/rules/explicit-function-return-type.md) | Require explicit return types on functions and class methods | :heavy_check_mark: | | | | [`@typescript-eslint/explicit-member-accessibility`](./docs/rules/explicit-member-accessibility.md) | Require explicit accessibility modifiers on class properties and methods (`member-access` from TSLint) | :heavy_check_mark: | | | | [`@typescript-eslint/generic-type-naming`](./docs/rules/generic-type-naming.md) | Enforces naming of generic type variables | | | | +| [`@typescript-eslint/func-call-spacing`](./docs/rules/func-call-spacing.md) | Spacing between function identifiers and their invocations | | :wrench: | | | [`@typescript-eslint/indent`](./docs/rules/indent.md) | Enforce consistent indentation (`indent` from TSLint) | :heavy_check_mark: | :wrench: | | | [`@typescript-eslint/interface-name-prefix`](./docs/rules/interface-name-prefix.md) | Require that interface names be prefixed with `I` (`interface-name` from TSLint) | :heavy_check_mark: | | | -| [`@typescript-eslint/member-delimiter-style`](./docs/rules/member-delimiter-style.md) | Require a specific member delimiter style for interfaces and type literals | :heavy_check_mark: | :wrench: | +| [`@typescript-eslint/member-delimiter-style`](./docs/rules/member-delimiter-style.md) | Require a specific member delimiter style for interfaces and type literals | :heavy_check_mark: | :wrench: | | | [`@typescript-eslint/member-naming`](./docs/rules/member-naming.md) | Enforces naming conventions for class members by visibility. | | | | | [`@typescript-eslint/member-ordering`](./docs/rules/member-ordering.md) | Require a consistent member declaration order (`member-ordering` from TSLint) | | | | | [`@typescript-eslint/no-angle-bracket-type-assertion`](./docs/rules/no-angle-bracket-type-assertion.md) | Enforces the use of `as Type` assertions instead of `` assertions (`no-angle-bracket-type-assertion` from TSLint) | :heavy_check_mark: | | | diff --git a/packages/eslint-plugin/docs/rules/func-call-spacing.md b/packages/eslint-plugin/docs/rules/func-call-spacing.md new file mode 100644 index 00000000000..d852df451a1 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/func-call-spacing.md @@ -0,0 +1,26 @@ +# require or disallow spacing between function identifiers and their invocations (func-call-spacing) + +When calling a function, developers may insert optional whitespace between the function’s name and the parentheses that invoke it. +This rule requires or disallows spaces between the function name and the opening parenthesis that calls it. + +## Rule Details + +This rule extends the base [eslint/func-call-spacing](https://eslint.org/docs/rules/func-call-spacing) rule. +It supports all options and features of the base 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 + "func-call-spacing": "off", + "@typescript-eslint/func-call-spacing": ["error"] +} +``` + +## Options + +See [eslint/func-call-spacing options](https://eslint.org/docs/rules/func-call-spacing#options). + +Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/func-call-spacing.md) diff --git a/packages/eslint-plugin/src/rules/func-call-spacing.ts b/packages/eslint-plugin/src/rules/func-call-spacing.ts new file mode 100644 index 00000000000..63f9d05e19e --- /dev/null +++ b/packages/eslint-plugin/src/rules/func-call-spacing.ts @@ -0,0 +1,153 @@ +import { TSESTree } from '@typescript-eslint/typescript-estree'; +import { isOpeningParenToken } from 'eslint-utils'; +import * as util from '../util'; + +export type Options = [ + 'never' | 'always', + { + allowNewlines?: boolean; + }? +]; +export type MessageIds = 'unexpected' | 'missing'; + +export default util.createRule({ + name: 'func-call-spacing', + meta: { + type: 'layout', + docs: { + description: + 'require or disallow spacing between function identifiers and their invocations', + category: 'Stylistic Issues', + recommended: false, + }, + fixable: 'whitespace', + schema: { + anyOf: [ + { + type: 'array', + items: [ + { + enum: ['never'], + }, + ], + minItems: 0, + maxItems: 1, + }, + { + type: 'array', + items: [ + { + enum: ['always'], + }, + { + type: 'object', + properties: { + allowNewlines: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + minItems: 0, + maxItems: 2, + }, + ], + }, + + messages: { + unexpected: + 'Unexpected space or newline between function name and paren.', + missing: 'Missing space between function name and paren.', + }, + }, + defaultOptions: ['never', {}], + create(context, [option, config]) { + const sourceCode = context.getSourceCode(); + const text = sourceCode.getText(); + + /** + * Check if open space is present in a function name + * @param {ASTNode} node node to evaluate + * @returns {void} + * @private + */ + function checkSpacing( + node: TSESTree.CallExpression | TSESTree.NewExpression, + ): void { + const closingParenToken = sourceCode.getLastToken(node)!; + const lastCalleeTokenWithoutPossibleParens = sourceCode.getLastToken( + node.typeParameters || node.callee, + )!; + const openingParenToken = sourceCode.getFirstTokenBetween( + lastCalleeTokenWithoutPossibleParens, + closingParenToken, + isOpeningParenToken, + ); + if (!openingParenToken || openingParenToken.range[1] >= node.range[1]) { + // new expression with no parens... + return; + } + const lastCalleeToken = sourceCode.getTokenBefore(openingParenToken)!; + + const textBetweenTokens = text + .slice(lastCalleeToken.range[1], openingParenToken.range[0]) + .replace(/\/\*.*?\*\//gu, ''); + const hasWhitespace = /\s/u.test(textBetweenTokens); + const hasNewline = + hasWhitespace && util.LINEBREAK_MATCHER.test(textBetweenTokens); + + if (option === 'never') { + if (hasWhitespace) { + return context.report({ + node, + loc: lastCalleeToken.loc.start, + messageId: 'unexpected', + fix(fixer) { + /* + * Only autofix if there is no newline + * https://github.com/eslint/eslint/issues/7787 + */ + if (!hasNewline) { + return fixer.removeRange([ + lastCalleeToken.range[1], + openingParenToken.range[0], + ]); + } + + return null; + }, + }); + } + } else { + if (!hasWhitespace) { + context.report({ + node, + loc: lastCalleeToken.loc.start, + messageId: 'missing', + fix(fixer) { + return fixer.insertTextBefore(openingParenToken, ' '); + }, + }); + } else if (!config!.allowNewlines && hasNewline) { + context.report({ + node, + loc: lastCalleeToken.loc.start, + messageId: 'unexpected', + fix(fixer) { + return fixer.replaceTextRange( + [lastCalleeToken.range[1], openingParenToken.range[0]], + ' ', + ); + }, + }); + } + } + } + + return { + CallExpression: checkSpacing, + NewExpression: checkSpacing, + }; + }, +}); diff --git a/packages/eslint-plugin/src/util/astUtils.ts b/packages/eslint-plugin/src/util/astUtils.ts new file mode 100644 index 00000000000..6dae402dce9 --- /dev/null +++ b/packages/eslint-plugin/src/util/astUtils.ts @@ -0,0 +1 @@ +export const LINEBREAK_MATCHER = /\r\n|[\r\n\u2028\u2029]/; diff --git a/packages/eslint-plugin/src/util/index.ts b/packages/eslint-plugin/src/util/index.ts index 56890ae19fc..d2291cded47 100644 --- a/packages/eslint-plugin/src/util/index.ts +++ b/packages/eslint-plugin/src/util/index.ts @@ -1,4 +1,5 @@ export * from './applyDefault'; +export * from './astUtils'; export * from './createRule'; export * from './deepMerge'; export * from './getParserServices'; diff --git a/packages/eslint-plugin/tests/rules/func-call-spacing.test.ts b/packages/eslint-plugin/tests/rules/func-call-spacing.test.ts new file mode 100644 index 00000000000..580f2a57d30 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/func-call-spacing.test.ts @@ -0,0 +1,367 @@ +import rule, { MessageIds, Options } from '../../src/rules/func-call-spacing'; +import { RuleTester, ValidTestCase, InvalidTestCase } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('func-call-spacing', rule, { + valid: [ + ...[ + 'f();', + 'f(a, b);', + 'f.b();', + 'f.b().c();', + 'f()()', + '(function() {}())', + 'var f = new Foo()', + 'var f = new Foo', + 'f( (0) )', + '(function(){ if (foo) { bar(); } }());', + 'f(0, (1))', + "describe/**/('foo', function () {});", + 'new (foo())', + '( f )( 0 )', + '( (f) )( (0) )', + '( f()() )(0)', + 'f()', + 'f(b, b)', + 'f.b(b, b)', + '(function() {}())', + '((function() {})())', + '( f )( 0 )', + '( (f) )( (0) )', + '( f()() )(0)', + ].map>(code => ({ + code, + options: ['never'], + })), + + ...[ + 'f ();', + 'f (a, b);', + 'f.b ();', + 'f.b ().c ();', + 'f () ()', + '(function() {} ())', + 'var f = new Foo ()', + 'var f = new Foo', + 'f ( (0) )', + 'f (0) (1)', + 'f ();\n t ();', + '( f ) ( 0 )', + '( (f) ) ( (0) )', + 'f ()', + 'f (b, b)', + 'f.b (b, b)', + '(function() {} ())', + '((function() {}) ())', + '( f ) ( 0 )', + '( (f) ) ( (0) )', + '( f () ) (0)', + ].map>(code => ({ + code, + options: ['always'], + })), + ...[ + 'f\n();', + 'f.b \n ();', + 'f\n() ().b \n()\n ()', + 'var f = new Foo\n();', + 'f// comment\n()', + 'f // comment\n ()', + 'f\n/*\n*/\n()', + 'f\r();', + 'f\u2028();', + 'f\u2029();', + 'f\r\n();', + ].map>(code => ({ + code, + options: ['always', { allowNewlines: true }], + })), + ], + invalid: [ + // "never" + ...[ + { + code: 'f ();', + output: 'f();', + }, + { + code: 'f (a, b);', + output: 'f(a, b);', + }, + { + code: 'f.b ();', + output: 'f.b();', + errors: [{ messageId: 'unexpected', column: 3 }], + }, + { + code: 'f.b().c ();', + output: 'f.b().c();', + errors: [{ messageId: 'unexpected', column: 7 }], + }, + { + code: 'f() ()', + output: 'f()()', + }, + { + code: '(function() {} ())', + output: '(function() {}())', + }, + { + code: 'var f = new Foo ()', + output: 'var f = new Foo()', + }, + { + code: 'f ( (0) )', + output: 'f( (0) )', + }, + { + code: 'f(0) (1)', + output: 'f(0)(1)', + }, + { + code: 'f ();\n t ();', + output: 'f();\n t();', + errors: [{ messageId: 'unexpected' }, { messageId: 'unexpected' }], + }, + + // https://github.com/eslint/eslint/issues/7787 + { + code: 'f\n();', + output: null, // no change + }, + { + code: ` +this.cancelled.add(request) +this.decrement(request) +(request.reject(new api.Cancel())) + `, + output: null, // no change + errors: [ + { + messageId: 'unexpected', + line: 3, + column: 23, + }, + ], + }, + { + code: ` +var a = foo +(function(global) {}(this)); + `, + output: null, // no change + errors: [ + { + messageId: 'unexpected', + line: 2, + column: 9, + }, + ], + }, + { + code: ` +var a = foo +(baz()) + `, + output: null, // no change + errors: [ + { + messageId: 'unexpected', + line: 2, + column: 9, + }, + ], + }, + { + code: 'f\r();', + output: null, // no change + }, + { + code: 'f\u2028();', + output: null, // no change + }, + { + code: 'f\u2029();', + output: null, // no change + }, + { + code: 'f\r\n();', + output: null, // no change + }, + ].map>( + code => + ({ + options: ['never'], + errors: [{ messageId: 'unexpected' }], + ...code, + } as any), + ), + + // "always" + ...[ + { + code: 'f();', + output: 'f ();', + }, + { + code: 'f(a, b);', + output: 'f (a, b);', + }, + { + code: 'f() ()', + output: 'f () ()', + }, + { + code: 'var f = new Foo()', + output: 'var f = new Foo ()', + }, + { + code: 'f( (0) )', + output: 'f ( (0) )', + }, + { + code: 'f(0) (1)', + output: 'f (0) (1)', + }, + ].map>( + code => + ({ + options: ['always'], + errors: [{ messageId: 'missing' }], + ...code, + } as any), + ), + ...[ + { + code: 'f\n();', + output: 'f ();', + }, + { + code: 'f\n(a, b);', + output: 'f (a, b);', + }, + { + code: 'f.b();', + output: 'f.b ();', + errors: [{ messageId: 'missing' as MessageIds, column: 3 }], + }, + { + code: 'f.b\n();', + output: 'f.b ();', + }, + { + code: 'f.b().c ();', + output: 'f.b ().c ();', + errors: [{ messageId: 'missing' as MessageIds, column: 3 }], + }, + { + code: 'f.b\n().c ();', + output: 'f.b ().c ();', + }, + { + code: 'f\n() ()', + output: 'f () ()', + }, + { + code: 'f\n()()', + output: 'f () ()', + errors: [ + { messageId: 'unexpected' as MessageIds }, + { messageId: 'missing' as MessageIds }, + ], + }, + { + code: '(function() {}())', + output: '(function() {} ())', + errors: [{ messageId: 'missing' }], + }, + { + code: 'f();\n t();', + output: 'f ();\n t ();', + errors: [ + { messageId: 'missing' as MessageIds }, + { messageId: 'missing' as MessageIds }, + ], + }, + { + code: 'f\r();', + output: 'f ();', + }, + { + code: 'f\u2028();', + output: 'f ();', + }, + { + code: 'f\u2029();', + output: 'f ();', + }, + { + code: 'f\r\n();', + output: 'f ();', + }, + ].map>( + code => + ({ + options: ['always'], + errors: [{ messageId: 'unexpected' as MessageIds }], + ...code, + } as any), + ), + + // "always", "allowNewlines": true + ...[ + { + code: 'f();', + output: 'f ();', + }, + { + code: 'f(a, b);', + output: 'f (a, b);', + }, + { + code: 'f.b();', + output: 'f.b ();', + errors: [{ messageId: 'missing', column: 3 }], + }, + { + code: 'f.b().c ();', + output: 'f.b ().c ();', + }, + { + code: 'f() ()', + output: 'f () ()', + }, + { + code: '(function() {}())', + output: '(function() {} ())', + }, + { + code: 'var f = new Foo()', + output: 'var f = new Foo ()', + }, + { + code: 'f( (0) )', + output: 'f ( (0) )', + }, + { + code: 'f(0) (1)', + output: 'f (0) (1)', + }, + { + code: 'f();\n t();', + output: 'f ();\n t ();', + errors: [{ messageId: 'missing' }, { messageId: 'missing' }], + }, + ].map>( + code => + ({ + options: ['always', { allowNewlines: true }], + errors: [{ messageId: 'missing' }], + ...code, + } as any), + ), + ], +}); diff --git a/packages/eslint-plugin/typings/ts-eslint.d.ts b/packages/eslint-plugin/typings/ts-eslint.d.ts index 3053c988d41..83c58d548a6 100644 --- a/packages/eslint-plugin/typings/ts-eslint.d.ts +++ b/packages/eslint-plugin/typings/ts-eslint.d.ts @@ -325,7 +325,7 @@ declare module 'ts-eslint' { /** * An override of the location of the report */ - loc?: TSESTree.SourceLocation; + loc?: TSESTree.SourceLocation | TSESTree.LineAndColumnData; } interface RuleContext<