diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 809aac848a60..0f0f442be57b 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -191,6 +191,7 @@ In these cases, we create what we call an extension rule; a rule within our plug | [`@typescript-eslint/no-unused-vars`](./docs/rules/no-unused-vars.md) | Disallow unused variables | :heavy_check_mark: | | | | [`@typescript-eslint/no-use-before-define`](./docs/rules/no-use-before-define.md) | Disallow the use of variables before they are defined | :heavy_check_mark: | | | | [`@typescript-eslint/no-useless-constructor`](./docs/rules/no-useless-constructor.md) | Disallow unnecessary constructors | | | | +| [`@typescript-eslint/object-curly-spacing`](./docs/rules/object-curly-spacing.md) | Enforce consistent spacing inside braces | | :wrench: | | | [`@typescript-eslint/quotes`](./docs/rules/quotes.md) | Enforce the consistent use of either backticks, double, or single quotes | | :wrench: | | | [`@typescript-eslint/require-await`](./docs/rules/require-await.md) | Disallow async functions which have no `await` expression | :heavy_check_mark: | | :thought_balloon: | | [`@typescript-eslint/return-await`](./docs/rules/return-await.md) | Enforces consistent returning of awaited values | | :wrench: | :thought_balloon: | diff --git a/packages/eslint-plugin/docs/rules/object-curly-spacing.md b/packages/eslint-plugin/docs/rules/object-curly-spacing.md new file mode 100644 index 000000000000..f64b04dc6f5e --- /dev/null +++ b/packages/eslint-plugin/docs/rules/object-curly-spacing.md @@ -0,0 +1,22 @@ +# Enforce consistent spacing inside braces (`object-curly-spacing`) + +## Rule Details + +This rule extends the base [`eslint/object-curly-spacing`](https://eslint.org/docs/rules/object-curly-spacing) rule. +It supports all options and features of the base rule. + +## How to use + +```cjson +{ + // note you must disable the base rule as it can report incorrect errors + "object-curly-spacing": "off", + "@typescript-eslint/object-curly-spacing": ["error"] +} +``` + +## Options + +See [`eslint/object-curly-spacing` options](https://eslint.org/docs/rules/object-curly-spacing#options). + +Taken with ❤️ [from ESLint core](https://github.com/eslint/eslint/blob/master/docs/rules/object-curly-spacing.md) diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json index 85cf2e1ecc61..f67054d673f5 100644 --- a/packages/eslint-plugin/src/configs/all.json +++ b/packages/eslint-plugin/src/configs/all.json @@ -77,6 +77,8 @@ "no-useless-constructor": "off", "@typescript-eslint/no-useless-constructor": "error", "@typescript-eslint/no-var-requires": "error", + "@typescript-eslint/object-curly-spacing": "error", + "object-curly-spacing": "off", "@typescript-eslint/prefer-as-const": "error", "@typescript-eslint/prefer-for-of": "error", "@typescript-eslint/prefer-function-type": "error", diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 15ce8dd1a820..9c89f8ef5713 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -66,6 +66,7 @@ import noUnusedVarsExperimental from './no-unused-vars-experimental'; import noUseBeforeDefine from './no-use-before-define'; import noUselessConstructor from './no-useless-constructor'; import noVarRequires from './no-var-requires'; +import objectCurlySpacing from './object-curly-spacing'; import preferAsConst from './prefer-as-const'; import preferForOf from './prefer-for-of'; import preferFunctionType from './prefer-function-type'; @@ -163,6 +164,7 @@ export default { 'no-use-before-define': noUseBeforeDefine, 'no-useless-constructor': noUselessConstructor, 'no-var-requires': noVarRequires, + 'object-curly-spacing': objectCurlySpacing, 'prefer-as-const': preferAsConst, 'prefer-for-of': preferForOf, 'prefer-function-type': preferFunctionType, diff --git a/packages/eslint-plugin/src/rules/object-curly-spacing.ts b/packages/eslint-plugin/src/rules/object-curly-spacing.ts new file mode 100644 index 000000000000..44dcd89c41f4 --- /dev/null +++ b/packages/eslint-plugin/src/rules/object-curly-spacing.ts @@ -0,0 +1,273 @@ +import { + AST_NODE_TYPES, + AST_TOKEN_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import baseRule from 'eslint/lib/rules/object-curly-spacing'; +import { + createRule, + InferMessageIdsTypeFromRule, + InferOptionsTypeFromRule, + isClosingBraceToken, + isClosingBracketToken, + isTokenOnSameLine, +} from '../util'; + +export type Options = InferOptionsTypeFromRule; +export type MessageIds = InferMessageIdsTypeFromRule; + +export default createRule({ + name: 'object-curly-spacing', + meta: { + type: 'layout', + docs: { + description: 'Enforce consistent spacing inside braces', + category: 'Stylistic Issues', + recommended: false, + extendsBaseRule: true, + }, + messages: { + requireSpaceBefore: "A space is required before '{{token}}'.", + requireSpaceAfter: "A space is required after '{{token}}'.", + unexpectedSpaceBefore: "There should be no space before '{{token}}'.", + unexpectedSpaceAfter: "There should be no space after '{{token}}'.", + }, + fixable: baseRule.meta.fixable, + schema: baseRule.meta.schema, + }, + defaultOptions: ['never'], + create(context) { + const spaced = context.options[0] === 'always', + sourceCode = context.getSourceCode(); + + /** + * Determines whether an option is set, relative to the spacing option. + * If spaced is "always", then check whether option is set to false. + * If spaced is "never", then check whether option is set to true. + * @param {Object} option The option to exclude. + * @returns {boolean} Whether or not the property is excluded. + */ + function isOptionSet( + option: 'arraysInObjects' | 'objectsInObjects', + ): boolean { + return context.options[1] + ? context.options[1][option] === !spaced + : false; + } + + const options = { + spaced, + arraysInObjectsException: isOptionSet('arraysInObjects'), + objectsInObjectsException: isOptionSet('objectsInObjects'), + }; + + //-------------------------------------------------------------------------- + // Helpers + //-------------------------------------------------------------------------- + + /** + * Reports that there shouldn't be a space after the first token + * @param node The node to report in the event of an error. + * @param token The token to use for the report. + */ + function reportNoBeginningSpace( + node: TSESTree.TSTypeLiteral, + token: TSESTree.Token, + ): void { + const nextToken = context + .getSourceCode() + .getTokenAfter(token, { includeComments: true })!; + + context.report({ + node, + loc: { start: token.loc.end, end: nextToken.loc.start }, + messageId: 'unexpectedSpaceAfter', + data: { + token: token.value, + }, + fix(fixer) { + return fixer.removeRange([token.range[1], nextToken.range[0]]); + }, + }); + } + + /** + * Reports that there shouldn't be a space before the last token + * @param node The node to report in the event of an error. + * @param token The token to use for the report. + */ + function reportNoEndingSpace( + node: TSESTree.TSTypeLiteral, + token: TSESTree.Token, + ): void { + const previousToken = context + .getSourceCode() + .getTokenBefore(token, { includeComments: true })!; + + context.report({ + node, + loc: { start: previousToken.loc.end, end: token.loc.start }, + messageId: 'unexpectedSpaceBefore', + data: { + token: token.value, + }, + fix(fixer) { + return fixer.removeRange([previousToken.range[1], token.range[0]]); + }, + }); + } + + /** + * Reports that there should be a space after the first token + * @param node The node to report in the event of an error. + * @param token The token to use for the report. + */ + function reportRequiredBeginningSpace( + node: TSESTree.TSTypeLiteral, + token: TSESTree.Token, + ): void { + context.report({ + node, + loc: token.loc, + messageId: 'requireSpaceAfter', + data: { + token: token.value, + }, + fix(fixer) { + return fixer.insertTextAfter(token, ' '); + }, + }); + } + + /** + * Reports that there should be a space before the last token + * @param node The node to report in the event of an error. + * @param token The token to use for the report. + */ + function reportRequiredEndingSpace( + node: TSESTree.TSTypeLiteral, + token: TSESTree.Token, + ): void { + context.report({ + node, + loc: token.loc, + messageId: 'requireSpaceBefore', + data: { + token: token.value, + }, + fix(fixer) { + return fixer.insertTextBefore(token, ' '); + }, + }); + } + + /** + * Determines if spacing in curly braces is valid. + * @param node The AST node to check. + * @param first The first token to check (should be the opening brace) + * @param second The second token to check (should be first after the opening brace) + * @param penultimate The penultimate token to check (should be last before closing brace) + * @param last The last token to check (should be closing brace) + */ + function validateBraceSpacing( + node: TSESTree.TSTypeLiteral, + first: TSESTree.Token, + second: TSESTree.Token | TSESTree.Comment, + penultimate: TSESTree.Token | TSESTree.Comment, + last: TSESTree.Token, + ): void { + if (isTokenOnSameLine(first, second)) { + const firstSpaced = sourceCode.isSpaceBetween(first, second); + const secondType = sourceCode.getNodeByRangeIndex(second.range[0])! + .type; + + const openingCurlyBraceMustBeSpaced = + options.arraysInObjectsException && + secondType === AST_NODE_TYPES.TSIndexSignature + ? !options.spaced + : options.spaced; + + if (openingCurlyBraceMustBeSpaced && !firstSpaced) { + reportRequiredBeginningSpace(node, first); + } + if ( + !openingCurlyBraceMustBeSpaced && + firstSpaced && + second.type !== AST_TOKEN_TYPES.Line + ) { + reportNoBeginningSpace(node, first); + } + } + + if (isTokenOnSameLine(penultimate, last)) { + const shouldCheckPenultimate = + (options.arraysInObjectsException && + isClosingBracketToken(penultimate)) || + (options.objectsInObjectsException && + isClosingBraceToken(penultimate)); + const penultimateType = + shouldCheckPenultimate && + sourceCode.getNodeByRangeIndex(penultimate.range[0])!.type; + + const closingCurlyBraceMustBeSpaced = + (options.arraysInObjectsException && + penultimateType === AST_NODE_TYPES.TSTupleType) || + (options.objectsInObjectsException && + penultimateType === AST_NODE_TYPES.TSTypeLiteral) + ? !options.spaced + : options.spaced; + + const lastSpaced = sourceCode.isSpaceBetween(penultimate, last); + + if (closingCurlyBraceMustBeSpaced && !lastSpaced) { + reportRequiredEndingSpace(node, last); + } + if (!closingCurlyBraceMustBeSpaced && lastSpaced) { + reportNoEndingSpace(node, last); + } + } + } + + /** + * Gets '}' token of an object node. + * + * Because the last token of object patterns might be a type annotation, + * this traverses tokens preceded by the last property, then returns the + * first '}' token. + * @param node The node to get. This node is an + * ObjectExpression or an ObjectPattern. And this node has one or + * more properties. + * @returns '}' token. + */ + function getClosingBraceOfObject( + node: TSESTree.TSTypeLiteral, + ): TSESTree.Token | null { + const lastProperty = node.members[node.members.length - 1]; + + return sourceCode.getTokenAfter(lastProperty, isClosingBraceToken); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + const rules = baseRule.create(context); + return { + ...rules, + TSTypeLiteral(node: TSESTree.TSTypeLiteral): void { + if (node.members.length === 0) { + return; + } + + const first = sourceCode.getFirstToken(node)!, + last = getClosingBraceOfObject(node)!, + second = sourceCode.getTokenAfter(first, { includeComments: true })!, + penultimate = sourceCode.getTokenBefore(last, { + includeComments: true, + })!; + + validateBraceSpacing(node, first, second, penultimate, last); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/object-curly-spacing.test.ts b/packages/eslint-plugin/tests/rules/object-curly-spacing.test.ts new file mode 100644 index 000000000000..610a34f86917 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/object-curly-spacing.test.ts @@ -0,0 +1,199 @@ +/* eslint-disable eslint-comments/no-use */ +// this rule tests the position of braces, 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/object-curly-spacing'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('object-curly-spacing', rule, { + valid: [ + { + code: 'const x:{}', + }, + { + code: 'const x:{ }', + }, + { + code: 'const x:{f: number}', + }, + { + code: 'const x:{ // line-comment\nf: number\n}', + }, + { + code: 'const x:{// line-comment\nf: number\n}', + }, + { + code: 'const x:{/* inline-comment */f: number/* inline-comment */}', + }, + { + code: 'const x:{\nf: number\n}', + }, + { + code: 'const x:{f: {g: number}}', + }, + { + code: 'const x:{f: [number]}', + }, + { + code: 'const x:{[key: string]: value}', + }, + { + code: 'const x:{[key: string]: [number]}', + }, + { + code: 'const x:{f: {g: number} }', + options: ['never', { objectsInObjects: true }], + }, + { + code: 'const x:{f: {g: number}}', + options: ['never', { objectsInObjects: false }], + }, + { + code: 'const x:{f: () => {g: number} }', + options: ['never', { objectsInObjects: true }], + }, + { + code: 'const x:{f: () => {g: number}}', + options: ['never', { objectsInObjects: false }], + }, + { + code: 'const x:{f: [number] }', + options: ['never', { arraysInObjects: true }], + }, + { + code: 'const x:{f: [ number ]}', + options: ['never', { arraysInObjects: false }], + }, + { + code: 'const x:{ [key: string]: value}', + options: ['never', { arraysInObjects: true }], + }, + { + code: 'const x:{[key: string]: value}', + options: ['never', { arraysInObjects: false }], + }, + { + code: 'const x:{ [key: string]: [number] }', + options: ['never', { arraysInObjects: true }], + }, + { + code: 'const x:{[key: string]: [number]}', + options: ['never', { arraysInObjects: false }], + }, + { + code: 'const x:{}', + options: ['always'], + }, + { + code: 'const x:{ }', + options: ['always'], + }, + { + code: 'const x:{ f: number }', + options: ['always'], + }, + { + code: 'const x:{ // line-comment\nf: number\n}', + options: ['always'], + }, + { + code: 'const x:{ /* inline-comment */ f: number /* inline-comment */ }', + options: ['always'], + }, + { + code: 'const x:{\nf: number\n}', + options: ['always'], + }, + { + code: 'const x:{ f: [number] }', + options: ['always'], + }, + { + code: 'const x:{ f: { g: number } }', + options: ['always', { objectsInObjects: true }], + }, + { + code: 'const x:{ f: { g: number }}', + options: ['always', { objectsInObjects: false }], + }, + { + code: 'const x:{ f: () => { g: number } }', + options: ['always', { objectsInObjects: true }], + }, + { + code: 'const x:{ f: () => { g: number }}', + options: ['always', { objectsInObjects: false }], + }, + { + code: 'const x:{ f: [number] }', + options: ['always', { arraysInObjects: true }], + }, + { + code: 'const x:{ f: [ number ]}', + options: ['always', { arraysInObjects: false }], + }, + { + code: 'const x:{ [key: string]: value }', + options: ['always', { arraysInObjects: true }], + }, + { + code: 'const x:{[key: string]: value }', + options: ['always', { arraysInObjects: false }], + }, + { + code: 'const x:{ [key: string]: [number] }', + options: ['always', { arraysInObjects: true }], + }, + { + code: 'const x:{[key: string]: [number]}', + options: ['always', { arraysInObjects: false }], + }, + ], + + invalid: [ + { + code: 'type x = { f: number }', + output: 'type x = {f: number}', + errors: [ + { messageId: 'unexpectedSpaceAfter' }, + { messageId: 'unexpectedSpaceBefore' }, + ], + }, + { + code: 'type x = { f: number}', + output: 'type x = {f: number}', + errors: [{ messageId: 'unexpectedSpaceAfter' }], + }, + { + code: 'type x = {f: number }', + output: 'type x = {f: number}', + errors: [{ messageId: 'unexpectedSpaceBefore' }], + }, + { + code: 'type x = {f: number}', + output: 'type x = { f: number }', + options: ['always'], + errors: [ + { messageId: 'requireSpaceAfter' }, + { messageId: 'requireSpaceBefore' }, + ], + }, + { + code: 'type x = {f: number }', + output: 'type x = { f: number }', + options: ['always'], + errors: [{ messageId: 'requireSpaceAfter' }], + }, + { + code: 'type x = { f: number}', + output: 'type x = { f: number }', + options: ['always'], + errors: [{ messageId: 'requireSpaceBefore' }], + }, + ], +}); diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts index ea60d9b31697..4cb7efdc57a8 100644 --- a/packages/eslint-plugin/typings/eslint-rules.d.ts +++ b/packages/eslint-plugin/typings/eslint-rules.d.ts @@ -543,3 +543,28 @@ declare module 'eslint/lib/rules/no-extra-semi' { >; export = rule; } + +declare module 'eslint/lib/rules/object-curly-spacing' { + import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; + + const rule: TSESLint.RuleModule< + | 'requireSpaceBefore' + | 'requireSpaceAfter' + | 'unexpectedSpaceBefore' + | 'unexpectedSpaceAfter', + [ + 'always' | 'never', + { + arraysInObjects?: boolean; + objectsInObjects?: boolean; + }?, + ], + { + ObjectPattern(node: TSESTree.ObjectPattern): void; + ObjectExpression(node: TSESTree.ObjectExpression): void; + ImportDeclaration(node: TSESTree.ImportDeclaration): void; + ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration): void; + } + >; + export = rule; +}