eslint-plugin-jest-formatting
Version:
ESLint rules for formatting jest tests
357 lines (315 loc) • 10.5 kB
text/typescript
/**
* 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,
};
},
});