diff --git a/packages/eslint-plugin/docs/rules/block-spacing.md b/packages/eslint-plugin/docs/rules/block-spacing.md new file mode 100644 index 000000000000..6a902214bbb4 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/block-spacing.md @@ -0,0 +1,12 @@ +--- +description: 'Disallow or enforce spaces inside of blocks after opening block and before closing block.' +--- + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/block-spacing** for documentation. + +## Examples + +This rule extends the base [`eslint/block-spacing`](https://eslint.org/docs/rules/block-spacing) rule. +This version adds support for TypeScript related blocks (interfaces, object type literals and enums). diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index eb3856f10c3b..834f4dbb7aea 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -11,6 +11,8 @@ export = { '@typescript-eslint/ban-ts-comment': 'error', '@typescript-eslint/ban-tslint-comment': 'error', '@typescript-eslint/ban-types': 'error', + 'block-spacing': 'off', + '@typescript-eslint/block-spacing': 'error', 'brace-style': 'off', '@typescript-eslint/brace-style': 'error', '@typescript-eslint/class-literal-property-style': 'error', diff --git a/packages/eslint-plugin/src/rules/block-spacing.ts b/packages/eslint-plugin/src/rules/block-spacing.ts new file mode 100644 index 000000000000..745132978f77 --- /dev/null +++ b/packages/eslint-plugin/src/rules/block-spacing.ts @@ -0,0 +1,163 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_TOKEN_TYPES } from '@typescript-eslint/utils'; + +import * as util from '../util'; +import { getESLintCoreRule } from '../util/getESLintCoreRule'; + +const baseRule = getESLintCoreRule('block-spacing'); + +export type Options = util.InferOptionsTypeFromRule; +export type MessageIds = util.InferMessageIdsTypeFromRule; + +export default util.createRule({ + name: 'block-spacing', + meta: { + type: 'layout', + docs: { + description: + 'Disallow or enforce spaces inside of blocks after opening block and before closing block', + recommended: false, + extendsBaseRule: true, + }, + fixable: 'whitespace', + hasSuggestions: baseRule.meta.hasSuggestions, + schema: baseRule.meta.schema, + messages: baseRule.meta.messages, + }, + defaultOptions: ['always'], + + create(context, [whenToApplyOption]) { + const sourceCode = context.getSourceCode(); + const baseRules = baseRule.create(context); + const always = whenToApplyOption !== 'never'; + const messageId = always ? 'missing' : 'extra'; + /** + * Gets the open brace token from a given node. + * @returns The token of the open brace. + */ + function getOpenBrace( + node: TSESTree.TSEnumDeclaration, + ): TSESTree.PunctuatorToken { + // guaranteed for enums + // This is the only change made here from the base rule + return sourceCode.getFirstToken(node, { + filter: token => + token.type === AST_TOKEN_TYPES.Punctuator && token.value === '{', + }) as TSESTree.PunctuatorToken; + } + + /** + * Checks whether or not: + * - given tokens are on same line. + * - there is/isn't a space between given tokens. + * @param left A token to check. + * @param right The token which is next to `left`. + * @returns + * When the option is `"always"`, `true` if there are one or more spaces between given tokens. + * When the option is `"never"`, `true` if there are not any spaces between given tokens. + * If given tokens are not on same line, it's always `true`. + */ + function isValid(left: TSESTree.Token, right: TSESTree.Token): boolean { + return ( + !util.isTokenOnSameLine(left, right) || + sourceCode.isSpaceBetween!(left, right) === always + ); + } + + /** + * Checks and reports invalid spacing style inside braces. + */ + function checkSpacingInsideBraces(node: TSESTree.TSEnumDeclaration): void { + // Gets braces and the first/last token of content. + const openBrace = getOpenBrace(node); + const closeBrace = sourceCode.getLastToken(node)!; + const firstToken = sourceCode.getTokenAfter(openBrace, { + includeComments: true, + })!; + const lastToken = sourceCode.getTokenBefore(closeBrace, { + includeComments: true, + })!; + + // Skip if the node is invalid or empty. + if ( + openBrace.type !== AST_TOKEN_TYPES.Punctuator || + openBrace.value !== '{' || + closeBrace.type !== AST_TOKEN_TYPES.Punctuator || + closeBrace.value !== '}' || + firstToken === closeBrace + ) { + return; + } + + // Skip line comments for option never + if (!always && firstToken.type === AST_TOKEN_TYPES.Line) { + return; + } + + if (!isValid(openBrace, firstToken)) { + let loc = openBrace.loc; + + if (messageId === 'extra') { + loc = { + start: openBrace.loc.end, + end: firstToken.loc.start, + }; + } + + context.report({ + node, + loc, + messageId, + data: { + location: 'after', + token: openBrace.value, + }, + fix(fixer) { + if (always) { + return fixer.insertTextBefore(firstToken, ' '); + } + + return fixer.removeRange([openBrace.range[1], firstToken.range[0]]); + }, + }); + } + if (!isValid(lastToken, closeBrace)) { + let loc = closeBrace.loc; + + if (messageId === 'extra') { + loc = { + start: lastToken.loc.end, + end: closeBrace.loc.start, + }; + } + context.report({ + node, + loc, + messageId, + data: { + location: 'before', + token: closeBrace.value, + }, + fix(fixer) { + if (always) { + return fixer.insertTextAfter(lastToken, ' '); + } + + return fixer.removeRange([lastToken.range[1], closeBrace.range[0]]); + }, + }); + } + } + return { + ...baseRules, + + // This code worked "out of the box" for interface and type literal + // Enums were very close to match as well, the only reason they are not is that was that enums don't have a body node in the parser + // So the opening brace punctuator starts in the middle of the node - `getFirstToken` in + // the base rule did not filter for the first opening brace punctuator + TSInterfaceBody: baseRules.BlockStatement as never, + TSTypeLiteral: baseRules.BlockStatement as never, + TSEnumDeclaration: checkSpacingInsideBraces, + }; + }, +}); diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index bbddfc8d4709..4f96ec29364f 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -4,6 +4,7 @@ import awaitThenable from './await-thenable'; import banTsComment from './ban-ts-comment'; import banTslintComment from './ban-tslint-comment'; import banTypes from './ban-types'; +import blockSpacing from './block-spacing'; import braceStyle from './brace-style'; import classLiteralPropertyStyle from './class-literal-property-style'; import commaDangle from './comma-dangle'; @@ -137,6 +138,7 @@ export default { 'ban-ts-comment': banTsComment, 'ban-tslint-comment': banTslintComment, 'ban-types': banTypes, + 'block-spacing': blockSpacing, 'brace-style': braceStyle, 'class-literal-property-style': classLiteralPropertyStyle, 'comma-dangle': commaDangle, diff --git a/packages/eslint-plugin/src/util/getESLintCoreRule.ts b/packages/eslint-plugin/src/util/getESLintCoreRule.ts index 80962a677b05..ae68a317e080 100644 --- a/packages/eslint-plugin/src/util/getESLintCoreRule.ts +++ b/packages/eslint-plugin/src/util/getESLintCoreRule.ts @@ -7,6 +7,7 @@ const isESLintV8 = semver.major(version) >= 8; interface RuleMap { /* eslint-disable @typescript-eslint/consistent-type-imports -- more concise to use inline imports */ 'arrow-parens': typeof import('eslint/lib/rules/arrow-parens'); + 'block-spacing': typeof import('eslint/lib/rules/block-spacing'); 'brace-style': typeof import('eslint/lib/rules/brace-style'); 'comma-dangle': typeof import('eslint/lib/rules/comma-dangle'); 'dot-notation': typeof import('eslint/lib/rules/dot-notation'); diff --git a/packages/eslint-plugin/tests/rules/block-spacing.test.ts b/packages/eslint-plugin/tests/rules/block-spacing.test.ts new file mode 100644 index 000000000000..49578b8c0618 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/block-spacing.test.ts @@ -0,0 +1,145 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import rule from '../../src/rules/block-spacing'; +import type { InvalidTestCase, ValidTestCase } from '../RuleTester'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +type InvalidBlockSpacingTestCase = InvalidTestCase< + 'missing' | 'extra', + ['always' | 'never'] +>; + +const options = ['always', 'never'] as const; +const typeDeclarations = [ + { + nodeType: AST_NODE_TYPES.TSInterfaceBody, + stringPrefix: 'interface Foo ', + }, + { + nodeType: AST_NODE_TYPES.TSTypeLiteral, + stringPrefix: 'type Foo = ', + }, + { + nodeType: AST_NODE_TYPES.TSEnumDeclaration, + stringPrefix: 'enum Foo ', + }, + { + nodeType: AST_NODE_TYPES.TSEnumDeclaration, + stringPrefix: 'const enum Foo ', + }, +]; +const emptyBlocks = ['{}', '{ }']; +const singlePropertyBlocks = ['{bar: true}', '{ bar: true }']; +const blockComment = '/* comment */'; + +ruleTester.run('block-spacing', rule, { + valid: [ + // Empty blocks don't apply + ...options.flatMap(option => + typeDeclarations.flatMap(typeDec => + emptyBlocks.map>(blockType => ({ + code: typeDec.stringPrefix + blockType, + options: [option], + })), + ), + ), + ...typeDeclarations.flatMap>( + typeDec => { + const property = + typeDec.nodeType === AST_NODE_TYPES.TSEnumDeclaration + ? 'bar = 1' + : 'bar: true;'; + return [ + { + code: `${typeDec.stringPrefix}{ /* comment */ ${property} /* comment */ } // always`, + options: ['always'], + }, + { + code: `${typeDec.stringPrefix}{/* comment */ ${property} /* comment */} // never`, + options: ['never'], + }, + { + code: `${typeDec.stringPrefix}{ //comment\n ${property}}`, + options: ['never'], + }, + ]; + }, + ), + ], + invalid: [ + ...options.flatMap(option => + typeDeclarations.flatMap(typeDec => { + return singlePropertyBlocks.flatMap( + (blockType, blockIndex) => { + // These are actually valid, so filter them out + if ( + (option === 'always' && blockType.startsWith('{ ')) || + (option === 'never' && blockType.startsWith('{bar')) + ) { + return []; + } + const reverseBlockType = singlePropertyBlocks[1 - blockIndex]; + let code = `${typeDec.stringPrefix}${blockType}; /* ${option} */`; + let output = `${typeDec.stringPrefix}${reverseBlockType}; /* ${option} */`; + if (typeDec.nodeType === AST_NODE_TYPES.TSEnumDeclaration) { + output = output.replace(':', '='); + code = code.replace(':', '='); + } + + return { + code, + options: [option], + output, + errors: [ + { + type: typeDec.nodeType, + messageId: option === 'always' ? 'missing' : 'extra', + data: { location: 'after', token: '{' }, + }, + { + type: typeDec.nodeType, + messageId: option === 'always' ? 'missing' : 'extra', + data: { location: 'before', token: '}' }, + }, + ], + }; + }, + ); + }), + ), + // With block comments + ...options.flatMap(option => + typeDeclarations.flatMap(typeDec => { + const property = + typeDec.nodeType === AST_NODE_TYPES.TSEnumDeclaration + ? 'bar = 1' + : 'bar: true;'; + const alwaysSpace = option === 'always' ? '' : ' '; + const neverSpace = option === 'always' ? ' ' : ''; + return [ + { + code: `${typeDec.stringPrefix}{${alwaysSpace}${blockComment}${property}${blockComment}${alwaysSpace}} /* ${option} */`, + output: `${typeDec.stringPrefix}{${neverSpace}${blockComment}${property}${blockComment}${neverSpace}} /* ${option} */`, + options: [option], + errors: [ + { + type: typeDec.nodeType, + messageId: option === 'always' ? 'missing' : 'extra', + data: { location: 'after', token: '{' }, + }, + { + type: typeDec.nodeType, + messageId: option === 'always' ? 'missing' : 'extra', + data: { location: 'before', token: '}' }, + }, + ], + }, + ]; + }), + ), + ], +}); diff --git a/packages/eslint-plugin/typings/eslint-rules.d.ts b/packages/eslint-plugin/typings/eslint-rules.d.ts index 38682f60c5b2..f5e0f715f0b4 100644 --- a/packages/eslint-plugin/typings/eslint-rules.d.ts +++ b/packages/eslint-plugin/typings/eslint-rules.d.ts @@ -661,6 +661,21 @@ declare module 'eslint/lib/rules/quotes' { export = rule; } +declare module 'eslint/lib/rules/block-spacing' { + import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; + + const rule: TSESLint.RuleModule< + 'missing' | 'extra', + ['always' | 'never'], + { + BlockStatement(node: TSESTree.BlockStatement): void; + StaticBlock(node: TSESTree.StaticBlock): void; + SwitchStatement(node: TSESTree.SwitchStatement): void; + } + >; + export = rule; +} + declare module 'eslint/lib/rules/brace-style' { import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy