From a1ad1966d836b9cd634c8679d924898b4244d06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Verit=C3=A9=20Mugabo?= Date: Fri, 24 May 2024 21:45:54 -0400 Subject: [PATCH] formating rules port (#461) * feat(initial): setup rules * chore(final-touches): nothing --- package.json | 2 +- src/index.ts | 28 +- src/rules/padding-around-after-all-blocks.ts | 18 + src/rules/padding-around-after-each-blocks.ts | 19 ++ src/rules/padding-around-all.ts | 26 ++ src/rules/padding-around-before-all-blocks.ts | 19 ++ .../padding-around-before-each-blocks.ts | 24 ++ src/rules/padding-around-describe-blocks.ts | 32 ++ src/rules/padding-around-expect-groups.ts | 28 ++ src/rules/padding-around-test-blocks.ts | 36 ++ src/utils/ast-utils.ts | 78 +++++ src/utils/msc.ts | 9 +- src/utils/padding.ts | 317 ++++++++++++++++++ tests/padding-around-after-all-blocks.test.ts | 89 +++++ .../padding-around-after-each-blocks.test.ts | 88 +++++ tests/padding-around-all.test.ts | 221 ++++++++++++ .../padding-around-before-all-blocks.test.ts | 90 +++++ .../padding-around-before-each-blocks.test.ts | 90 +++++ tests/padding-around-describe-blocks.test.ts | 126 +++++++ tests/padding-around-expect-groups.test.ts | 192 +++++++++++ tests/padding-around-test-blocks.test.ts | 137 ++++++++ 21 files changed, 1665 insertions(+), 4 deletions(-) create mode 100644 src/rules/padding-around-after-all-blocks.ts create mode 100644 src/rules/padding-around-after-each-blocks.ts create mode 100644 src/rules/padding-around-all.ts create mode 100644 src/rules/padding-around-before-all-blocks.ts create mode 100644 src/rules/padding-around-before-each-blocks.ts create mode 100644 src/rules/padding-around-describe-blocks.ts create mode 100644 src/rules/padding-around-expect-groups.ts create mode 100644 src/rules/padding-around-test-blocks.ts create mode 100644 src/utils/ast-utils.ts create mode 100644 src/utils/padding.ts create mode 100644 tests/padding-around-after-all-blocks.test.ts create mode 100644 tests/padding-around-after-each-blocks.test.ts create mode 100644 tests/padding-around-all.test.ts create mode 100644 tests/padding-around-before-all-blocks.test.ts create mode 100644 tests/padding-around-before-each-blocks.test.ts create mode 100644 tests/padding-around-describe-blocks.test.ts create mode 100644 tests/padding-around-expect-groups.test.ts create mode 100644 tests/padding-around-test-blocks.test.ts diff --git a/package.json b/package.json index cc18aba..b1be3ba 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ ], "author": "Verite Mugabo ", "type": "module", - "main": "./dist/index.mjs", + "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { diff --git a/src/index.ts b/src/index.ts index 6660b2a..eab9b00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,14 @@ import preferSpyOn, { RULE_NAME as preferSpyOnName } from './rules/prefer-spy-on import preferComparisonMatcher, { RULE_NAME as preferComparisonMatcherName } from './rules/prefer-comparison-matcher' import preferToContain, { RULE_NAME as preferToContainName } from './rules/prefer-to-contain' import preferExpectAssertions, { RULE_NAME as preferExpectAssertionsName } from './rules/prefer-expect-assertions' +import paddingAroundAfterAllBlocks, { RULE_NAME as paddingAroundAfterAllBlocksName } from "./rules/padding-around-after-all-blocks" +import paddingAroundAfterEachBlocks, { RULE_NAME as paddingAroundAfterEachBlocksName } from "./rules/padding-around-after-each-blocks" +import paddingAroundAll, { RULE_NAME as paddingAroundAllName } from "./rules/padding-around-all" +import paddingAroundBeforeAllBlocks, { RULE_NAME as paddingAroundBeforeAllBlocksName } from "./rules/padding-around-before-all-blocks" +import paddingAroundBeforeEachBlocks, { RULE_NAME as paddingAroundBeforeEachBlocksName } from "./rules/padding-around-before-each-blocks" +import paddingAroundDescribeBlocks, { RULE_NAME as paddingAroundDescribeBlocksName } from "./rules/padding-around-describe-blocks" +import paddingAroundExpectGroups, { RULE_NAME as paddingAroundExpectGroupsName } from "./rules/padding-around-expect-groups" +import paddingAroundTestBlocks, { RULE_NAME as paddingAroundTestBlocksName } from "./rules/padding-around-test-blocks" const createConfig = (rules: R) => ( Object.keys(rules).reduce((acc, ruleName) => { @@ -117,7 +125,15 @@ const allRules = { [preferComparisonMatcherName]: 'warn', [preferToContainName]: 'warn', [preferExpectAssertionsName]: 'warn', - [usePreferTobe]: 'warn' + [usePreferTobe]: 'warn', + [paddingAroundAfterAllBlocksName]: 'warn', + [paddingAroundAfterEachBlocksName]: 'warn', + [paddingAroundAllName]: 'warn', + [paddingAroundBeforeAllBlocksName]: 'warn', + [paddingAroundBeforeEachBlocksName]: 'warn', + [paddingAroundDescribeBlocksName]: 'warn', + [paddingAroundExpectGroupsName]: 'warn', + [paddingAroundTestBlocksName]: 'warn' } as const const recommended = { @@ -188,7 +204,15 @@ const plugin = { [preferSpyOnName]: preferSpyOn, [preferComparisonMatcherName]: preferComparisonMatcher, [preferToContainName]: preferToContain, - [preferExpectAssertionsName]: preferExpectAssertions + [preferExpectAssertionsName]: preferExpectAssertions, + [paddingAroundAfterAllBlocksName]: paddingAroundAfterAllBlocks, + [paddingAroundAfterEachBlocksName]: paddingAroundAfterEachBlocks, + [paddingAroundAllName]: paddingAroundAll, + [paddingAroundBeforeAllBlocksName]: paddingAroundBeforeAllBlocks, + [paddingAroundBeforeEachBlocksName]: paddingAroundBeforeEachBlocks, + [paddingAroundDescribeBlocksName]: paddingAroundDescribeBlocks, + [paddingAroundExpectGroupsName]: paddingAroundExpectGroups, + [paddingAroundTestBlocksName]: paddingAroundTestBlocks }, configs: { 'legacy-recommended': createConfigLegacy(recommended), diff --git a/src/rules/padding-around-after-all-blocks.ts b/src/rules/padding-around-after-all-blocks.ts new file mode 100644 index 0000000..5137dc9 --- /dev/null +++ b/src/rules/padding-around-after-all-blocks.ts @@ -0,0 +1,18 @@ +import { Config, PaddingType, StatementType, createPaddingRule } from "../utils/padding" +import { URL } from "node:url" + +export const RULE_NAME = new URL('', import.meta.url).pathname + +export const config: Config[] = [ + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.Any, + nextStatementType: StatementType.AfterAllToken + }, + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.AfterAllToken, + nextStatementType: StatementType.Any + } +] +export default createPaddingRule(RULE_NAME, 'Enforce padding around `afterAll` blocks', config) diff --git a/src/rules/padding-around-after-each-blocks.ts b/src/rules/padding-around-after-each-blocks.ts new file mode 100644 index 0000000..4d93910 --- /dev/null +++ b/src/rules/padding-around-after-each-blocks.ts @@ -0,0 +1,19 @@ +import { Config, PaddingType, StatementType, createPaddingRule } from "../utils/padding"; +import { get_filename } from "../utils/msc"; + +export const RULE_NAME = get_filename(import.meta.url) + +export const config: Config[] = [ + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.Any, + nextStatementType: StatementType.AfterEachToken + }, + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.AfterEachToken, + nextStatementType: StatementType.Any + } +] + +export default createPaddingRule(RULE_NAME, 'Enforce padding around `afterEach` blocks', config) diff --git a/src/rules/padding-around-all.ts b/src/rules/padding-around-all.ts new file mode 100644 index 0000000..6acb69a --- /dev/null +++ b/src/rules/padding-around-all.ts @@ -0,0 +1,26 @@ + +import { config as paddingAroundAfterAllBlocksConfig } from './padding-around-after-all-blocks'; +import { config as paddingAroundAfterEachBlocksConfig } from './padding-around-after-each-blocks'; +import { config as paddingAroundBeforeAllBlocksConfig } from './padding-around-before-all-blocks'; +import { config as paddingAroundBeforeEachBlocksConfig } from './padding-around-before-each-blocks'; +import { config as paddingAroundDescribeBlocksConfig } from './padding-around-describe-blocks'; +import { config as paddingAroundExpectGroupsConfig } from './padding-around-expect-groups'; +import { config as paddingAroundTestBlocksConfig } from './padding-around-test-blocks'; +import { createPaddingRule } from '../utils/padding'; +import { get_filename } from '../utils/msc'; + +export const RULE_NAME = get_filename(import.meta.url) + +export default createPaddingRule( + RULE_NAME, + 'Enforce padding around vitest functions', + [ + ...paddingAroundAfterAllBlocksConfig, + ...paddingAroundAfterEachBlocksConfig, + ...paddingAroundBeforeAllBlocksConfig, + ...paddingAroundBeforeEachBlocksConfig, + ...paddingAroundDescribeBlocksConfig, + ...paddingAroundExpectGroupsConfig, + ...paddingAroundTestBlocksConfig, + ], +); diff --git a/src/rules/padding-around-before-all-blocks.ts b/src/rules/padding-around-before-all-blocks.ts new file mode 100644 index 0000000..262c15a --- /dev/null +++ b/src/rules/padding-around-before-all-blocks.ts @@ -0,0 +1,19 @@ +import { get_filename } from "../utils/msc"; +import { Config, PaddingType, StatementType, createPaddingRule } from "../utils/padding"; + +export const RULE_NAME = get_filename(import.meta.url) + +export const config: Config[] = [ + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.Any, + nextStatementType: StatementType.BeforeAllToken + }, + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.BeforeAllToken, + nextStatementType: StatementType.Any + } +] + +export default createPaddingRule(RULE_NAME, 'Enforce padding around `beforeAll` blocks', config) diff --git a/src/rules/padding-around-before-each-blocks.ts b/src/rules/padding-around-before-each-blocks.ts new file mode 100644 index 0000000..4b8b9a3 --- /dev/null +++ b/src/rules/padding-around-before-each-blocks.ts @@ -0,0 +1,24 @@ + +import { get_filename } from '../utils/msc'; +import { PaddingType, StatementType, createPaddingRule } from '../utils/padding'; + +export const RULE_NAME = get_filename(import.meta.url) + +export const config = [ + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.Any, + nextStatementType: StatementType.BeforeEachToken, + }, + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.BeforeEachToken, + nextStatementType: StatementType.Any, + }, +]; + +export default createPaddingRule( + RULE_NAME, + 'Enforce padding around `beforeEach` blocks', + config, +); diff --git a/src/rules/padding-around-describe-blocks.ts b/src/rules/padding-around-describe-blocks.ts new file mode 100644 index 0000000..083be13 --- /dev/null +++ b/src/rules/padding-around-describe-blocks.ts @@ -0,0 +1,32 @@ + +import { get_filename } from '../utils/msc'; +import { Config, PaddingType, StatementType, createPaddingRule } from '../utils/padding'; + +export const RULE_NAME = get_filename(import.meta.url) + +export const config: Config[] = [ + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.Any, + nextStatementType: [ + StatementType.DescribeToken, + StatementType.FdescribeToken, + StatementType.XdescribeToken, + ], + }, + { + paddingType: PaddingType.Always, + prevStatementType: [ + StatementType.DescribeToken, + StatementType.FdescribeToken, + StatementType.XdescribeToken, + ], + nextStatementType: StatementType.Any, + }, +]; + +export default createPaddingRule( + RULE_NAME, + 'Enforce padding around `describe` blocks', + config, +); diff --git a/src/rules/padding-around-expect-groups.ts b/src/rules/padding-around-expect-groups.ts new file mode 100644 index 0000000..5c09a00 --- /dev/null +++ b/src/rules/padding-around-expect-groups.ts @@ -0,0 +1,28 @@ +import { get_filename } from "../utils/msc"; +import { Config, PaddingType, StatementType, createPaddingRule } from "../utils/padding"; + +export const RULE_NAME = get_filename(import.meta.url) + +export const config: Config[] = [ + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.Any, + nextStatementType: StatementType.ExpectToken, + }, + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.ExpectToken, + nextStatementType: StatementType.Any, + }, + { + paddingType: PaddingType.Any, + prevStatementType: StatementType.ExpectToken, + nextStatementType: StatementType.ExpectToken, + }, +]; + +export default createPaddingRule( + RULE_NAME, + 'Enforce padding around `expect` groups', + config, +); diff --git a/src/rules/padding-around-test-blocks.ts b/src/rules/padding-around-test-blocks.ts new file mode 100644 index 0000000..e8c19d2 --- /dev/null +++ b/src/rules/padding-around-test-blocks.ts @@ -0,0 +1,36 @@ + +import { get_filename } from '../utils/msc'; +import { PaddingType, StatementType, createPaddingRule } from '../utils/padding'; + +export const RULE_NAME = get_filename(import.meta.url) + +export const config = [ + { + paddingType: PaddingType.Always, + prevStatementType: StatementType.Any, + nextStatementType: [ + StatementType.TestToken, + StatementType.ItToken, + StatementType.FitToken, + StatementType.XitToken, + StatementType.XtestToken, + ], + }, + { + paddingType: PaddingType.Always, + prevStatementType: [ + StatementType.TestToken, + StatementType.ItToken, + StatementType.FitToken, + StatementType.XitToken, + StatementType.XtestToken, + ], + nextStatementType: StatementType.Any, + }, +]; + +export default createPaddingRule( + RULE_NAME, + 'Enforce padding around afterAll blocks', + config, +); diff --git a/src/utils/ast-utils.ts b/src/utils/ast-utils.ts new file mode 100644 index 0000000..3bba1b5 --- /dev/null +++ b/src/utils/ast-utils.ts @@ -0,0 +1,78 @@ +import { AST_NODE_TYPES, AST_TOKEN_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils"; +import { createRequire } from "node:module" + +const eslintRequire = createRequire(require.resolve("eslint")) + +export const espreeParser = eslintRequire.resolve('espree'); + + +// We'll only verify nodes with these parent types +const STATEMENT_LIST_PARENTS = new Set([ + AST_NODE_TYPES.Program, + AST_NODE_TYPES.BlockStatement, + AST_NODE_TYPES.SwitchCase, + AST_NODE_TYPES.SwitchStatement, +]); + +export const isValidParent = (parentType: AST_NODE_TYPES): boolean => { + return STATEMENT_LIST_PARENTS.has(parentType) +} + +export const isTokenASemicolon = (token: TSESTree.Token): boolean => + token.value === ';' && token.type === AST_TOKEN_TYPES.Punctuator + + +/** + * Gets the actual last token. + * + * If a semicolon is semicolon-less style's semicolon, this ignores it. + * For example: + * + * foo() + * ;[1, 2, 3].forEach(bar) + */ +export const getActualLastToken = (sourceCode: TSESLint.SourceCode, node: TSESTree.Node): TSESTree.Token => { + + const semiToken = sourceCode.getLastToken(node)!; + const prevToken = sourceCode.getTokenBefore(semiToken)!; + const nextToken = sourceCode.getTokenAfter(semiToken) + + const isSemicolonLessStyle = Boolean( + prevToken && + nextToken && + prevToken.range[0] >= node.range[0] && + isTokenASemicolon(semiToken) && + semiToken.loc.start.line !== prevToken.loc.end.line && + semiToken.loc.end.line === nextToken.loc.start.line, + ); + + return isSemicolonLessStyle ? prevToken : semiToken; + +} + +export const getPaddingLineSequences = (prevNode: TSESTree.Node, nextNode: TSESTree.Node, sourceCode: TSESLint.SourceCode) => { + const pairs: TSESTree.Token[][] = [] + const includeComments = true + + let prevToken = getActualLastToken(sourceCode, prevNode) + + if ((nextNode.loc.start.line - prevNode.loc.end.line) >= 2) { + do { + const token = sourceCode.getTokenAfter(prevToken, { includeComments }) as TSESTree.Token + + if ((token.loc.start.line - prevToken.loc.end.line) >= 2) { + pairs.push([prevToken, token]) + } + + prevToken = token + } while (prevToken.range[0] < nextNode.range[0]) + } + + return pairs +} + + +export const areTokensOnSameLine = ( + left: TSESTree.Node | TSESTree.Token, + right: TSESTree.Node | TSESTree.Token, +): boolean => left.loc.end.line === right.loc.start.line; diff --git a/src/utils/msc.ts b/src/utils/msc.ts index ca4cf16..db4b7cc 100644 --- a/src/utils/msc.ts +++ b/src/utils/msc.ts @@ -2,6 +2,8 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' import { getFirstMatcherArg, ParsedExpectVitestFnCall } from './parse-vitest-fn-call' import { EqualityMatcher } from './types' import { getAccessorValue, isSupportedAccessor } from '.' +import { basename, parse } from "node:path"; +import { fileURLToPath } from 'node:url'; export const isBooleanLiteral = (node: TSESTree.Node): node is TSESTree.BooleanLiteral => node.type === AST_NODE_TYPES.Literal && typeof node.value === 'boolean' @@ -35,7 +37,7 @@ export const isInstanceOfBinaryExpression = ( && isSupportedAccessor(node.right, className) export interface CallExpressionWithSingleArgument< - Argument extends TSESTree.CallExpression['arguments'][number] = TSESTree.CallExpression['arguments'][number] + Argument extends TSESTree.CallExpression['arguments'][number] = TSESTree.CallExpression['arguments'][number] > extends TSESTree.CallExpression { arguments: [Argument] } @@ -43,3 +45,8 @@ export interface CallExpressionWithSingleArgument< export const hasOnlyOneArgument = ( call: TSESTree.CallExpression ): call is CallExpressionWithSingleArgument => call.arguments.length === 1 + + +export function get_filename(url: string) { + return parse(basename(fileURLToPath(url))).name +} diff --git a/src/utils/padding.ts b/src/utils/padding.ts new file mode 100644 index 0000000..607eb6e --- /dev/null +++ b/src/utils/padding.ts @@ -0,0 +1,317 @@ +//Imported from https://github.com/dangreenisrael/eslint-plugin-jest-formatting/blob/master/src/rules/padding.ts +//Original license: https://github.com/dangreenisrael/eslint-plugin-jest-formatting/blob/master/LICENSE + +import { getSourceCode } from "@typescript-eslint/utils/eslint-utils" +import { createEslintRule } from "." +import { AST_NODE_TYPES, AST_TOKEN_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils"; +import * as astUtils from "./ast-utils" + +export const enum PaddingType { + Any, + Always +} + +export const enum StatementType { + Any, + AfterAllToken, + AfterEachToken, + BeforeAllToken, + BeforeEachToken, + DescribeToken, + ExpectToken, + FdescribeToken, + FitToken, + ItToken, + TestToken, + XdescribeToken, + XitToken, + XtestToken, +} + +export interface Config { + paddingType: PaddingType, + prevStatementType: StatementType | StatementType[], + nextStatementType: StatementType | StatementType[] +} + +interface ScopeInfo { + prevNode: TSESTree.Node | null; + enter: () => void; + exit: () => void; +} + +// Tracks position in scope and prevNode. Used to compare current and prev node +// and then to walk back up to the parent scope or down into the next one. +// And so on... +interface Scope { + upper: Scope | null; + prevNode: TSESTree.Node | null; +} + +type PaddingTester = ( + prevNode: TSESTree.Node, + nextNode: TSESTree.Node, + paddingContext: PaddingContext, +) => void; + +interface PaddingContext { + ruleContext: TSESLint.RuleContext<'missingPadding', unknown[]>; + sourceCode: TSESLint.SourceCode; + scopeInfo: ScopeInfo; + configs: Config[]; +} + +const paddingAlwaysTester = (prevNode: TSESTree.Node, nextNode: TSESTree.Node, paddingContext: PaddingContext): void => { + const { sourceCode, ruleContext } = paddingContext + + const paddingLines = astUtils.getPaddingLineSequences(prevNode, nextNode, sourceCode) + + if (paddingLines.length > 0) return + + + ruleContext.report({ + node: nextNode, + messageId: "missingPadding", + fix(fixer: TSESLint.RuleFixer) { + let prevToken = astUtils.getActualLastToken(sourceCode, prevNode) + const nextToken = (sourceCode.getFirstTokenBetween(prevToken, nextNode, { + includeComments: true, + /** + * Skip the trailing comments of the previous node. + * This inserts a blank line after the last trailing comment. + * + * For example: + * + * foo(); // trailing comment. + * // comment. + * bar(); + * + * Get fixed to: + * + * foo(); // trailing comment. + * + * // comment. + * bar(); + */ + filter(token: TSESTree.Token): boolean { + if (astUtils.areTokensOnSameLine(prevToken, token)) { + prevToken = token + + return false + } + + return true + } + }) || nextNode) as TSESTree.Token + + const insertText = astUtils.areTokensOnSameLine(prevToken, nextToken) ? '\n\n' : '\n' + + return fixer.insertTextAfter(prevToken, insertText) + } + }) +} + +// A mapping of PaddingType to PaddingTester +const paddingTesters: { [T in PaddingType]: PaddingTester } = { + [PaddingType.Any]: () => true, + [PaddingType.Always]: paddingAlwaysTester, +}; + + +const createScopeInfo = (): ScopeInfo => { + let scope: Scope | null = null + + return { + get prevNode() { + return scope!.prevNode + }, + set prevNode(node) { + scope!.prevNode = node; + }, + enter() { + scope = { upper: scope, prevNode: null } + }, + exit() { + scope = scope!.upper + } + } +} + + +const createTokenTester = (tokenName: string): StatementTester => { + return (node: TSESTree.Node, sourceCode: TSESLint.SourceCode): boolean => { + let activeNode = node; + + if (activeNode.type === AST_NODE_TYPES.ExpressionStatement) { + // In the case of `await`, we actually care about its argument + if (activeNode.expression.type === AST_NODE_TYPES.AwaitExpression) { + activeNode = activeNode.expression.argument; + } + + const token = sourceCode.getFirstToken(activeNode); + + return ( + token?.type === AST_TOKEN_TYPES.Identifier && token.value === tokenName + ); + } + + return false; + }; +}; + + +type StatementTester = ( + node: TSESTree.Node, + sourceCode: TSESLint.SourceCode, +) => boolean; + +type StatementTypes = StatementType | StatementType[]; + +// A mapping of StatementType to StatementTester for... testing statements +const statementTesters: { [T in StatementType]: StatementTester } = { + [StatementType.Any]: () => true, + [StatementType.AfterAllToken]: createTokenTester('afterAll'), + [StatementType.AfterEachToken]: createTokenTester('afterEach'), + [StatementType.BeforeAllToken]: createTokenTester('beforeAll'), + [StatementType.BeforeEachToken]: createTokenTester('beforeEach'), + [StatementType.DescribeToken]: createTokenTester('describe'), + [StatementType.ExpectToken]: createTokenTester('expect'), + [StatementType.FdescribeToken]: createTokenTester('fdescribe'), + [StatementType.FitToken]: createTokenTester('fit'), + [StatementType.ItToken]: createTokenTester('it'), + [StatementType.TestToken]: createTokenTester('test'), + [StatementType.XdescribeToken]: createTokenTester('xdescribe'), + [StatementType.XitToken]: createTokenTester('xit'), + [StatementType.XtestToken]: createTokenTester('xtest'), +}; + +/** + * Check whether the given node matches the statement type + */ +const nodeMatchesType = ( + node: TSESTree.Node, + statementType: StatementTypes, + paddingContext: PaddingContext, +): boolean => { + let innerStatementNode = node; + const { sourceCode } = paddingContext; + + // Dig into LabeledStatement body until it's not that anymore + while (innerStatementNode.type === AST_NODE_TYPES.LabeledStatement) { + innerStatementNode = innerStatementNode.body; + } + + // If it's an array recursively check if any of the statement types match + // the node + if (Array.isArray(statementType)) { + return statementType.some(type => + nodeMatchesType(innerStatementNode, type, paddingContext), + ); + } + + return statementTesters[statementType](innerStatementNode, sourceCode); +}; + +const testPadding = (prevNode: TSESTree.Node, nextNode: TSESTree.Node, paddingContext: PaddingContext): void => { + const { configs } = paddingContext + + const testType = (type: PaddingType) => paddingTesters[type](prevNode, nextNode, paddingContext) + + + for (let i = configs.length - 1; i >= 0; --i) { + const { prevStatementType: prevType, nextStatementType: nextType, paddingType } = configs[i]; + + if (nodeMatchesType(prevNode, prevType, paddingContext) && nodeMatchesType(nextNode, nextType, paddingContext)) { + return testType(paddingType) + } + } + + return testType(PaddingType.Any) +} + +const verifyNode = (node: TSESTree.Node, paddingContext: PaddingContext): void => { + const { scopeInfo } = paddingContext + + // NOTE: ESLint types use ESTree which provides a Node type, however + // ESTree.Node doesn't support the parent property which is added by + // ESLint during traversal. Our best bet is to ignore the property access + // here as it's the only place that it's checked. + + if (!astUtils.isValidParent((node as any)?.parent.type)) return + + if (scopeInfo.prevNode) { + testPadding(scopeInfo.prevNode, node, paddingContext) + } + + scopeInfo.prevNode = node +} + + +/** + * Creates an ESLint rule for a given set of padding Config objects. + * + * The algorithm is approximately this: + * + * For each 'scope' in the program + * - Enter the scope (store the parent scope and previous node) + * - For each statement in the scope + * - Check the current node and previous node against the Config objects + * - If the current node and previous node match a Config, check the padding. + * Otherwise, ignore it. + * - If the padding is missing (and required), report and fix + * - Store the current node as the previous + * - Repeat + * - Exit scope (return to parent scope and clear previous node) + * + * The items we're looking for with this rule are ExpressionStatement nodes + * where the first token is an Identifier with a name matching one of the vitest + * functions. It's not foolproof, of course, but it's probably good enough for + * almost all cases. + * + * The Config objects specify a padding type, a previous statement type, and a + * next statement type. Wildcard statement types and padding types are + * supported. The current node and previous node are checked against the + * statement types. If they match then the specified padding type is + * tested/enforced. + */ +export const createPaddingRule = (name: string, description: string, configs: Config[], deprecated = false) => { + return createEslintRule({ + name, + meta: { + docs: { description }, + fixable: 'whitespace', + deprecated, + messages: { + missingPadding: 'expect blank line before this statement' + }, + schema: [], + type: 'suggestion' + }, + defaultOptions: [], + create(context) { + const paddingContext = { + ruleContext: context, + sourceCode: getSourceCode(context), + scopeInfo: createScopeInfo(), + configs + } + + const { scopeInfo } = paddingContext; + + return { + Program: scopeInfo.enter, + 'Program:exit': scopeInfo.exit, + BlockStatement: scopeInfo.enter, + 'BlockStatement:exit': scopeInfo.exit, + SwitchStatement: scopeInfo.enter, + 'SwitchStatement:exit': scopeInfo.exit, + ':statement': (node: TSESTree.Node) => verifyNode(node, paddingContext), + SwitchCase(node: TSESTree.Node) { + verifyNode(node, paddingContext) + scopeInfo.enter() + }, + 'SwitchCase:exit': scopeInfo.exit + } + } + }) +} diff --git a/tests/padding-around-after-all-blocks.test.ts b/tests/padding-around-after-all-blocks.test.ts new file mode 100644 index 0000000..3e5411f --- /dev/null +++ b/tests/padding-around-after-all-blocks.test.ts @@ -0,0 +1,89 @@ +import { ruleTester } from "./ruleTester" +import rule, { RULE_NAME } from "../src/rules/padding-around-after-all-blocks" +import { TSESLint } from "@typescript-eslint/utils"; + +const testCase = { + code: ` +const someText = 'abc'; +afterAll(() => { +}); +describe('someText', () => { + const something = 'abc'; + // A comment + afterAll(() => { + // stuff + }); + afterAll(() => { + // other stuff + }); +}); + +describe('someText', () => { + const something = 'abc'; + afterAll(() => { + // stuff + }); +}); +`, + output: ` +const someText = 'abc'; + +afterAll(() => { +}); + +describe('someText', () => { + const something = 'abc'; + + // A comment + afterAll(() => { + // stuff + }); + + afterAll(() => { + // other stuff + }); +}); + +describe('someText', () => { + const something = 'abc'; + + afterAll(() => { + // stuff + }); +}); +`, + errors: [ + { + messageId: 'missingPadding', + line: 3, + column: 1, + }, + { + messageId: 'missingPadding', + line: 5, + column: 1, + }, + { + messageId: 'missingPadding', + line: 8, + column: 3, + }, + { + messageId: 'missingPadding', + line: 11, + column: 3, + }, + { + messageId: 'missingPadding', + line: 18, + column: 3, + }, + ], +} satisfies TSESLint.InvalidTestCase<'missingPadding', never>; + +ruleTester.run(RULE_NAME, rule, { + valid: [testCase.output], + invalid: ['src/component.test.jsx', 'src/component.test.js'].map( + filename => ({ ...testCase, filename }), + ), +}) diff --git a/tests/padding-around-after-each-blocks.test.ts b/tests/padding-around-after-each-blocks.test.ts new file mode 100644 index 0000000..10a7e72 --- /dev/null +++ b/tests/padding-around-after-each-blocks.test.ts @@ -0,0 +1,88 @@ + +import type { TSESLint } from '@typescript-eslint/utils'; +import rule, { RULE_NAME } from '../src/rules/padding-around-after-each-blocks'; +import { ruleTester } from './ruleTester'; + +const testCase = { + code: ` +const someText = 'abc'; +afterEach(() => { +}); +describe('someText', () => { + const something = 'abc'; + // A comment + afterEach(() => { + // stuff + }); + afterEach(() => { + // other stuff + }); +}); +describe('someText', () => { + const something = 'abc'; + afterEach(() => { + // stuff + }); +}); +`, + output: ` +const someText = 'abc'; + +afterEach(() => { +}); + +describe('someText', () => { + const something = 'abc'; + + // A comment + afterEach(() => { + // stuff + }); + + afterEach(() => { + // other stuff + }); +}); +describe('someText', () => { + const something = 'abc'; + + afterEach(() => { + // stuff + }); +}); +`, + errors: [ + { + messageId: 'missingPadding', + line: 3, + column: 1, + }, + { + messageId: 'missingPadding', + line: 5, + column: 1, + }, + { + messageId: 'missingPadding', + line: 8, + column: 3, + }, + { + messageId: 'missingPadding', + line: 11, + column: 3, + }, + { + messageId: 'missingPadding', + line: 17, + column: 3, + }, + ], +} satisfies TSESLint.InvalidTestCase<'missingPadding', never>; + +ruleTester.run(RULE_NAME, rule, { + valid: [testCase.output], + invalid: ['src/component.test.jsx', 'src/component.test.js'].map( + filename => ({ ...testCase, filename }), + ), +}); diff --git a/tests/padding-around-all.test.ts b/tests/padding-around-all.test.ts new file mode 100644 index 0000000..3757522 --- /dev/null +++ b/tests/padding-around-all.test.ts @@ -0,0 +1,221 @@ + +import type { TSESLint } from '@typescript-eslint/utils'; +import rule, { RULE_NAME } from '../src/rules/padding-around-all'; +import { ruleTester } from './ruleTester'; + +const testCase = { + code: ` +const someText = 'abc'; +afterAll(() => { +}); +describe('someText', () => { + const something = 'abc'; + // A comment + afterAll(() => { + // stuff + }); + afterAll(() => { + // other stuff + }); +}); + +describe('someText', () => { + const something = 'abc'; + afterAll(() => { + // stuff + }); +}); +`, + output: ` +const someText = 'abc'; + +afterAll(() => { +}); + +describe('someText', () => { + const something = 'abc'; + + // A comment + afterAll(() => { + // stuff + }); + + afterAll(() => { + // other stuff + }); +}); + +describe('someText', () => { + const something = 'abc'; + + afterAll(() => { + // stuff + }); +}); +`, + errors: [ + { + messageId: 'missingPadding', + line: 3, + column: 1, + }, + { + messageId: 'missingPadding', + line: 5, + column: 1, + }, + { + messageId: 'missingPadding', + line: 8, + column: 3, + }, + { + messageId: 'missingPadding', + line: 11, + column: 3, + }, + { + messageId: 'missingPadding', + line: 18, + column: 3, + }, + ], +} satisfies TSESLint.InvalidTestCase<'missingPadding', never>; + +ruleTester.run(RULE_NAME, rule, { + valid: [ + testCase.output, + ` + xyz: + afterEach(() => {}); + `, + ], + invalid: [ + ...['src/component.test.jsx', 'src/component.test.js'].map(filename => ({ + ...testCase, + filename, + })), + { + code: ` + const someText = 'abc' + ;afterEach(() => {}) + `, + output: ` + const someText = 'abc' + + ;afterEach(() => {}) + `, + errors: [ + { + messageId: 'missingPadding', + line: 3, + column: 10, + }, + ], + }, + { + code: ` + const someText = 'abc'; + xyz: + afterEach(() => {}); + `, + output: ` + const someText = 'abc'; + + xyz: + afterEach(() => {}); + `, + errors: [ + { + messageId: 'missingPadding', + line: 3, + column: 9, + }, + ], + }, + { + code: ` + const expr = 'Papayas'; + beforeEach(() => {}); + it('does something?', () => { + switch (expr) { + case 'Oranges': + expect(expr).toBe('Oranges'); + break; + case 'Mangoes': + case 'Papayas': + const v = 1; + expect(v).toBe(1); + console.log('Mangoes and papayas are $2.79 a pound.'); + // Expected output: "Mangoes and papayas are $2.79 a pound." + break; + default: + console.log(\`Sorry, we are out of $\{expr}.\`); + } + }); + `, + output: ` + const expr = 'Papayas'; + + beforeEach(() => {}); + + it('does something?', () => { + switch (expr) { + case 'Oranges': + expect(expr).toBe('Oranges'); + + break; + case 'Mangoes': + case 'Papayas': + const v = 1; + + expect(v).toBe(1); + + console.log('Mangoes and papayas are $2.79 a pound.'); + // Expected output: "Mangoes and papayas are $2.79 a pound." + break; + default: + console.log(\`Sorry, we are out of $\{expr}.\`); + } + }); + `, + errors: [ + { + messageId: 'missingPadding', + line: 3, + column: 9, + endLine: 3, + endColumn: 30, + }, + { + messageId: 'missingPadding', + line: 4, + column: 9, + endLine: 19, + endColumn: 12, + }, + { + messageId: 'missingPadding', + line: 8, + column: 15, + endLine: 8, + endColumn: 21, + }, + { + messageId: 'missingPadding', + line: 12, + column: 15, + endLine: 12, + endColumn: 33, + }, + { + messageId: 'missingPadding', + line: 13, + column: 15, + endLine: 13, + endColumn: 69, + }, + ], + }, + ], +}); diff --git a/tests/padding-around-before-all-blocks.test.ts b/tests/padding-around-before-all-blocks.test.ts new file mode 100644 index 0000000..4a66955 --- /dev/null +++ b/tests/padding-around-before-all-blocks.test.ts @@ -0,0 +1,90 @@ + +import type { TSESLint } from '@typescript-eslint/utils'; +import rule, { RULE_NAME } from '../src/rules/padding-around-before-all-blocks'; +import { ruleTester } from './ruleTester'; + +const testCase = { + code: ` +const someText = 'abc'; +beforeAll(() => { +}); +describe('someText', () => { + const something = 'abc'; + // A comment + beforeAll(() => { + // stuff + }); + beforeAll(() => { + // other stuff + }); +}); + +describe('someText', () => { + const something = 'abc'; + beforeAll(() => { + // stuff + }); +}); +`, + output: ` +const someText = 'abc'; + +beforeAll(() => { +}); + +describe('someText', () => { + const something = 'abc'; + + // A comment + beforeAll(() => { + // stuff + }); + + beforeAll(() => { + // other stuff + }); +}); + +describe('someText', () => { + const something = 'abc'; + + beforeAll(() => { + // stuff + }); +}); +`, + errors: [ + { + messageId: 'missingPadding', + line: 3, + column: 1, + }, + { + messageId: 'missingPadding', + line: 5, + column: 1, + }, + { + messageId: 'missingPadding', + line: 8, + column: 3, + }, + { + messageId: 'missingPadding', + line: 11, + column: 3, + }, + { + messageId: 'missingPadding', + line: 18, + column: 3, + }, + ], +} satisfies TSESLint.InvalidTestCase<'missingPadding', never>; + +ruleTester.run(RULE_NAME, rule, { + valid: [testCase.output], + invalid: ['src/component.test.jsx', 'src/component.test.js'].map( + filename => ({ ...testCase, filename }), + ), +}); diff --git a/tests/padding-around-before-each-blocks.test.ts b/tests/padding-around-before-each-blocks.test.ts new file mode 100644 index 0000000..497a12f --- /dev/null +++ b/tests/padding-around-before-each-blocks.test.ts @@ -0,0 +1,90 @@ + +import type { TSESLint } from '@typescript-eslint/utils'; +import rule, { RULE_NAME } from '../src/rules/padding-around-before-each-blocks'; +import { ruleTester } from './ruleTester'; + +const testCase = { + code: ` +const someText = 'abc'; +beforeEach(() => { +}); +describe('someText', () => { + const something = 'abc'; + // A comment + beforeEach(() => { + // stuff + }); + beforeEach(() => { + // other stuff + }); +}); + +describe('someText', () => { + const something = 'abc'; + beforeEach(() => { + // stuff + }); +}); +`, + output: ` +const someText = 'abc'; + +beforeEach(() => { +}); + +describe('someText', () => { + const something = 'abc'; + + // A comment + beforeEach(() => { + // stuff + }); + + beforeEach(() => { + // other stuff + }); +}); + +describe('someText', () => { + const something = 'abc'; + + beforeEach(() => { + // stuff + }); +}); +`, + errors: [ + { + messageId: 'missingPadding', + line: 3, + column: 1, + }, + { + messageId: 'missingPadding', + line: 5, + column: 1, + }, + { + messageId: 'missingPadding', + line: 8, + column: 3, + }, + { + messageId: 'missingPadding', + line: 11, + column: 3, + }, + { + messageId: 'missingPadding', + line: 18, + column: 3, + }, + ], +} satisfies TSESLint.InvalidTestCase<'missingPadding', never>; + +ruleTester.run(RULE_NAME, rule, { + valid: [testCase.output], + invalid: ['src/component.test.jsx', 'src/component.test.js'].map( + filename => ({ ...testCase, filename }), + ), +}); diff --git a/tests/padding-around-describe-blocks.test.ts b/tests/padding-around-describe-blocks.test.ts new file mode 100644 index 0000000..4116e76 --- /dev/null +++ b/tests/padding-around-describe-blocks.test.ts @@ -0,0 +1,126 @@ +import type { TSESLint } from '@typescript-eslint/utils'; +import rule, { RULE_NAME } from '../src/rules/padding-around-describe-blocks'; +import { ruleTester } from './ruleTester'; + +const testCase = { + code: ` +foo(); +bar(); + +const someText = 'abc'; +const someObject = { + one: 1, + two: 2, +}; +// A comment before describe +describe('someText', () => { + describe('some condition', () => { + }); + describe('some other condition', () => { + }); +}); +xdescribe('someObject', () => { + // Another comment + describe('some condition', () => { + const anotherThing = 500; + describe('yet another condition', () => { // A comment over here! + }); + }); +});fdescribe('weird', () => {}); +describe.skip('skip me', () => {}); +const BOOP = "boop"; +describe + .skip('skip me too', () => { + // stuff + }); +`, + output: ` +foo(); +bar(); + +const someText = 'abc'; +const someObject = { + one: 1, + two: 2, +}; + +// A comment before describe +describe('someText', () => { + describe('some condition', () => { + }); + + describe('some other condition', () => { + }); +}); + +xdescribe('someObject', () => { + // Another comment + describe('some condition', () => { + const anotherThing = 500; + + describe('yet another condition', () => { // A comment over here! + }); + }); +}); + +fdescribe('weird', () => {}); + +describe.skip('skip me', () => {}); + +const BOOP = "boop"; + +describe + .skip('skip me too', () => { + // stuff + }); +`, + errors: [ + { + messageId: 'missingPadding', + line: 11, + column: 1, + }, + { + messageId: 'missingPadding', + line: 14, + column: 3, + }, + { + messageId: 'missingPadding', + line: 17, + column: 1, + }, + { + messageId: 'missingPadding', + line: 21, + column: 5, + }, + { + messageId: 'missingPadding', + line: 24, + column: 4, + }, + { + messageId: 'missingPadding', + line: 25, + column: 1, + }, + { + messageId: 'missingPadding', + line: 26, + column: 1, + }, + { + messageId: 'missingPadding', + line: 27, + column: 1, + }, + ], +} satisfies TSESLint.InvalidTestCase<'missingPadding', never>; + +ruleTester.run(RULE_NAME, rule, { + valid: [testCase.output], + invalid: ['src/component.test.jsx', 'src/component.test.js'].map( + filename => ({ ...testCase, filename }), + ), +}); diff --git a/tests/padding-around-expect-groups.test.ts b/tests/padding-around-expect-groups.test.ts new file mode 100644 index 0000000..fbe66a7 --- /dev/null +++ b/tests/padding-around-expect-groups.test.ts @@ -0,0 +1,192 @@ + +import type { TSESLint } from '@typescript-eslint/utils'; +import rule, { RULE_NAME } from '../src/rules/padding-around-expect-groups'; +import { ruleTester } from './ruleTester'; + +const testCase = { + code: ` +foo(); +bar(); + +const someText = 'abc'; +const someObject = { + one: 1, + two: 2, +}; + +test('thing one', () => { + let abc = 123; + expect(abc).toEqual(123); + expect(123).toEqual(abc); // Line comment + abc = 456; + expect(abc).toEqual(456); +}); + +test('thing one', () => { + const abc = 123; + expect(abc).toEqual(123); + + const xyz = 987; + expect(123).toEqual(abc); // Line comment +}); + +describe('someText', () => { + describe('some condition', () => { + test('foo', () => { + const xyz = 987; + // Comment + expect(xyz).toEqual(987); + expect(1) + .toEqual(1); + expect(true).toEqual(true); + }); + }); +}); + +test('awaited expect', async () => { + const abc = 123; + const hasAPromise = () => Promise.resolve('foo'); + await expect(hasAPromise()).resolves.toEqual('foo'); + expect(abc).toEqual(123); + + const efg = 456; + expect(123).toEqual(abc); + await expect(hasAPromise()).resolves.toEqual('foo'); + + const hij = 789; + await expect(hasAPromise()).resolves.toEqual('foo'); + await expect(hasAPromise()).resolves.toEqual('foo'); + + const somethingElseAsync = () => Promise.resolve('bar'); + await somethingElseAsync(); + await expect(hasAPromise()).resolves.toEqual('foo'); +}); +`, + output: ` +foo(); +bar(); + +const someText = 'abc'; +const someObject = { + one: 1, + two: 2, +}; + +test('thing one', () => { + let abc = 123; + + expect(abc).toEqual(123); + expect(123).toEqual(abc); // Line comment + + abc = 456; + + expect(abc).toEqual(456); +}); + +test('thing one', () => { + const abc = 123; + + expect(abc).toEqual(123); + + const xyz = 987; + + expect(123).toEqual(abc); // Line comment +}); + +describe('someText', () => { + describe('some condition', () => { + test('foo', () => { + const xyz = 987; + + // Comment + expect(xyz).toEqual(987); + expect(1) + .toEqual(1); + expect(true).toEqual(true); + }); + }); +}); + +test('awaited expect', async () => { + const abc = 123; + const hasAPromise = () => Promise.resolve('foo'); + + await expect(hasAPromise()).resolves.toEqual('foo'); + expect(abc).toEqual(123); + + const efg = 456; + + expect(123).toEqual(abc); + await expect(hasAPromise()).resolves.toEqual('foo'); + + const hij = 789; + + await expect(hasAPromise()).resolves.toEqual('foo'); + await expect(hasAPromise()).resolves.toEqual('foo'); + + const somethingElseAsync = () => Promise.resolve('bar'); + await somethingElseAsync(); + + await expect(hasAPromise()).resolves.toEqual('foo'); +}); +`, + errors: [ + { + messageId: 'missingPadding', + line: 13, + column: 3, + }, + { + messageId: 'missingPadding', + line: 15, + column: 3, + }, + { + messageId: 'missingPadding', + line: 16, + column: 3, + }, + { + messageId: 'missingPadding', + line: 21, + column: 3, + }, + { + messageId: 'missingPadding', + line: 24, + column: 3, + }, + { + messageId: 'missingPadding', + line: 32, + column: 7, + }, + { + messageId: 'missingPadding', + line: 43, + column: 3, + }, + { + messageId: 'missingPadding', + line: 47, + column: 3, + }, + { + messageId: 'missingPadding', + line: 51, + column: 3, + }, + { + messageId: 'missingPadding', + line: 56, + column: 3, + }, + ], +} satisfies TSESLint.InvalidTestCase<'missingPadding', never>; + +ruleTester.run(RULE_NAME, rule, { + valid: [testCase.output], + invalid: ['src/component.test.jsx', 'src/component.test.js'].map( + filename => ({ ...testCase, filename }), + ), +}); diff --git a/tests/padding-around-test-blocks.test.ts b/tests/padding-around-test-blocks.test.ts new file mode 100644 index 0000000..8a2c424 --- /dev/null +++ b/tests/padding-around-test-blocks.test.ts @@ -0,0 +1,137 @@ +import type { TSESLint } from '@typescript-eslint/utils'; +import rule from '../src/rules/padding-around-test-blocks'; +import { ruleTester } from './ruleTester'; + +const testCase = { + code: ` +const foo = 'bar'; +const bar = 'baz'; +it('foo', () => { + // stuff +}); +fit('bar', () => { + // stuff +}); +test('foo foo', () => {}); +test('bar bar', () => {}); + +// Nesting +describe('other bar', () => { + const thing = 123; + test('is another bar w/ test', () => { + }); + // With a comment + it('is another bar w/ it', () => { + }); + test.skip('skipping', () => {}); // Another comment + it.skip('skipping too', () => {}); +});xtest('weird', () => {}); +test + .skip('skippy skip', () => {}); +xit('bar foo', () => {}); +`, + output: ` +const foo = 'bar'; +const bar = 'baz'; + +it('foo', () => { + // stuff +}); + +fit('bar', () => { + // stuff +}); + +test('foo foo', () => {}); + +test('bar bar', () => {}); + +// Nesting +describe('other bar', () => { + const thing = 123; + + test('is another bar w/ test', () => { + }); + + // With a comment + it('is another bar w/ it', () => { + }); + + test.skip('skipping', () => {}); // Another comment + + it.skip('skipping too', () => {}); +}); + +xtest('weird', () => {}); + +test + .skip('skippy skip', () => {}); + +xit('bar foo', () => {}); +`, + errors: [ + { + messageId: 'missingPadding', + line: 4, + column: 1, + }, + { + messageId: 'missingPadding', + line: 7, + column: 1, + }, + { + messageId: 'missingPadding', + line: 10, + column: 1, + }, + { + messageId: 'missingPadding', + line: 11, + column: 1, + }, + { + messageId: 'missingPadding', + line: 16, + column: 3, + }, + { + messageId: 'missingPadding', + line: 19, + column: 3, + }, + { + messageId: 'missingPadding', + line: 21, + column: 3, + }, + { + messageId: 'missingPadding', + line: 22, + column: 3, + }, + { + messageId: 'missingPadding', + line: 23, + column: 4, + }, + { + messageId: 'missingPadding', + line: 24, + column: 1, + }, + { + messageId: 'missingPadding', + line: 26, + column: 1, + }, + ], +} satisfies TSESLint.InvalidTestCase<'missingPadding', never>; + +ruleTester.run('padding-around-test-blocks', rule, { + valid: [testCase.output], + invalid: ['src/component.test.jsx', 'src/component.test.js'].map( + filename => ({ ...testCase, filename }), + ), +}); +