UNPKG

eslint-plugin-jest-formatting

Version:
357 lines (315 loc) 10.5 kB
/** * Require/fix newlines around jest functions * * Based on eslint/padding-line-between-statements by Toru Nagashima * See: https://github.com/eslint/eslint/blob/master/lib/rules/padding-line-between-statements.js * * Some helpers borrowed from eslint ast-utils by Gyandeep Singh * See: https://github.com/eslint/eslint/blob/master/lib/rules/utils/ast-utils.js */ import { AST, Rule, SourceCode } from 'eslint'; // This is because we are using @types/estree that are brought in with eslint // eslint-disable-next-line import/no-extraneous-dependencies import { Node } from 'estree'; import * as astUtils from '../ast-utils'; // Statement types we'll respond to export const enum StatementType { Any, AfterAllToken, AfterEachToken, BeforeAllToken, BeforeEachToken, DescribeToken, ExpectToken, FdescribeToken, FitToken, ItToken, TestToken, XdescribeToken, XitToken, XtestToken, } type StatementTypes = StatementType | StatementType[]; type StatementTester = (node: Node, sourceCode: SourceCode) => boolean; // Padding type to apply between statements export const enum PaddingType { Any, Always, } // A configuration object for padding type and the two statement types export interface Config { paddingType: PaddingType; prevStatementType: StatementTypes; nextStatementType: StatementTypes; } interface ScopeInfo { prevNode: Node | null; enter: () => void; exit: () => void; } interface PaddingContext { ruleContext: Rule.RuleContext; sourceCode: SourceCode; scopeInfo: ScopeInfo; configs: Config[]; } type PaddingTester = ( prevNode: Node, nextNode: Node, paddingContext: PaddingContext, ) => 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: Node | null; } // Creates a StatementTester to test an ExpressionStatement's first token name const createTokenTester = (tokenName: string): StatementTester => { return (node: Node, sourceCode: SourceCode): boolean => { let activeNode = node; if (activeNode.type === 'ExpressionStatement') { // In the case of `await`, we actually care about its argument if (activeNode.expression.type === 'AwaitExpression') { activeNode = activeNode.expression.argument; } const token = sourceCode.getFirstToken(activeNode); return token.type === 'Identifier' && token.value === tokenName; } return false; }; }; // 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 and report statements for `PaddingType.Always` configuration. * This autofix inserts a blank line between the given 2 statements. * If the `prevNode` has trailing comments, it inserts a blank line after the * trailing comments. */ const paddingAlwaysTester = ( prevNode: Node, nextNode: Node, paddingContext: PaddingContext, ): void => { const { sourceCode, ruleContext } = paddingContext; const paddingLines = astUtils.getPaddingLineSequences( prevNode, nextNode, sourceCode, ); // We've got some padding lines. Great. if (paddingLines.length > 0) { return; } // Missing padding line ruleContext.report({ node: nextNode, message: 'Expected blank line before this statement.', fix(fixer: Rule.RuleFixer): Rule.Fix { 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: AST.Token): boolean { if (astUtils.areTokensOnSameLine(prevToken, token)) { prevToken = token; return false; } return true; }, }) || nextNode) as AST.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 => { return (() => { let scope: Scope = null; return { get prevNode() { return scope.prevNode; }, set prevNode(node) { scope.prevNode = node; }, enter() { scope = { upper: scope, prevNode: null }; }, exit() { scope = scope.upper; }, }; })(); }; /** * Check whether the given node matches the statement type */ const nodeMatchesType = ( node: 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 === '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); }; /** * Executes matching padding tester for last matched padding config for given * nodes */ const testPadding = ( prevNode: Node, nextNode: 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); } } // There were no matching padding rules for the prevNode, nextNode, // paddingType combination... so we'll use PaddingType.Any which is always ok return testType(PaddingType.Any); }; /** * Verify padding lines between the given node and the previous node. */ const verifyNode = (node: 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. // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 Jest * 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. * * See src/index.ts for examples of Config usage. */ export const createRule = ( configs: Config[], deprecated = false, ): Rule.RuleModule => ({ meta: { fixable: 'whitespace', deprecated, }, create(context: Rule.RuleContext) { const paddingContext = { ruleContext: context, sourceCode: context.getSourceCode(), scopeInfo: createScopeInfo(), configs, }; const { scopeInfo } = paddingContext; return { Program: scopeInfo.enter, 'Program:exit': scopeInfo.enter, BlockStatement: scopeInfo.enter, 'BlockStatement:exit': scopeInfo.exit, SwitchStatement: scopeInfo.enter, 'SwitchStatement:exit': scopeInfo.exit, ':statement': (node: Node) => verifyNode(node, paddingContext), SwitchCase: (node: Node) => { verifyNode(node, paddingContext); scopeInfo.enter(); }, 'SwitchCase:exit': scopeInfo.exit, }; }, });