diff --git a/src/rules/__tests__/no-deprecated-functions.test.ts b/src/rules/__tests__/no-deprecated-functions.test.ts index ed2794499..f435bb0ab 100644 --- a/src/rules/__tests__/no-deprecated-functions.test.ts +++ b/src/rules/__tests__/no-deprecated-functions.test.ts @@ -1,8 +1,8 @@ import { TSESLint } from '@typescript-eslint/utils'; -import { JestVersion, detectJestVersion } from '../detectJestVersion'; import rule from '../no-deprecated-functions'; +import { JestVersion, detectJestVersion } from '../utils/detectJestVersion'; -jest.mock('../detectJestVersion'); +jest.mock('../utils/detectJestVersion'); const detectJestVersionMock = detectJestVersion as jest.MockedFunction< typeof detectJestVersion diff --git a/src/rules/no-deprecated-functions.ts b/src/rules/no-deprecated-functions.ts index 33b580322..be47bc9f1 100644 --- a/src/rules/no-deprecated-functions.ts +++ b/src/rules/no-deprecated-functions.ts @@ -1,6 +1,10 @@ import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; -import { JestVersion, detectJestVersion } from './detectJestVersion'; -import { createRule, getNodeName } from './utils'; +import { + JestVersion, + createRule, + detectJestVersion, + getNodeName, +} from './utils'; interface ContextSettings { jest?: EslintPluginJestSettings; diff --git a/src/rules/utils.ts b/src/rules/utils.ts deleted file mode 100644 index 30e4d7ba7..000000000 --- a/src/rules/utils.ts +++ /dev/null @@ -1,675 +0,0 @@ -import { parse as parsePath } from 'path'; -import { - AST_NODE_TYPES, - ESLintUtils, - TSESLint, - TSESTree, -} from '@typescript-eslint/utils'; -import { version } from '../../package.json'; -import { isTypeOfJestFnCall } from './utils/parseJestFnCall'; - -const REPO_URL = 'https://github.com/jest-community/eslint-plugin-jest'; - -export const createRule = ESLintUtils.RuleCreator(name => { - const ruleName = parsePath(name).name; - - return `${REPO_URL}/blob/v${version}/docs/rules/${ruleName}.md`; -}); - -export type MaybeTypeCast = - | TSTypeCastExpression - | Expression; - -type TSTypeCastExpression< - Expression extends TSESTree.Expression = TSESTree.Expression, -> = AsExpressionChain | TypeAssertionChain; - -interface AsExpressionChain< - Expression extends TSESTree.Expression = TSESTree.Expression, -> extends TSESTree.TSAsExpression { - expression: AsExpressionChain | Expression; -} - -interface TypeAssertionChain< - Expression extends TSESTree.Expression = TSESTree.Expression, -> extends TSESTree.TSTypeAssertion { - expression: TypeAssertionChain | Expression; -} - -const isTypeCastExpression = ( - node: MaybeTypeCast, -): node is TSTypeCastExpression => - node.type === AST_NODE_TYPES.TSAsExpression || - node.type === AST_NODE_TYPES.TSTypeAssertion; - -export const followTypeAssertionChain = < - Expression extends TSESTree.Expression, ->( - expression: MaybeTypeCast, -): Expression => - isTypeCastExpression(expression) - ? followTypeAssertionChain(expression.expression) - : expression; - -/** - * A `Literal` with a `value` of type `string`. - */ -interface StringLiteral - extends TSESTree.StringLiteral { - value: Value; -} - -/** - * Checks if the given `node` is a `StringLiteral`. - * - * If a `value` is provided & the `node` is a `StringLiteral`, - * the `value` will be compared to that of the `StringLiteral`. - * - * @param {Node} node - * @param {V} [value] - * - * @return {node is StringLiteral} - * - * @template V - */ -const isStringLiteral = ( - node: TSESTree.Node, - value?: V, -): node is StringLiteral => - node.type === AST_NODE_TYPES.Literal && - typeof node.value === 'string' && - (value === undefined || node.value === value); - -interface TemplateLiteral - extends TSESTree.TemplateLiteral { - quasis: [TSESTree.TemplateElement & { value: { raw: Value; cooked: Value } }]; -} - -/** - * Checks if the given `node` is a `TemplateLiteral`. - * - * Complex `TemplateLiteral`s are not considered specific, and so will return `false`. - * - * If a `value` is provided & the `node` is a `TemplateLiteral`, - * the `value` will be compared to that of the `TemplateLiteral`. - * - * @param {Node} node - * @param {V} [value] - * - * @return {node is TemplateLiteral} - * - * @template V - */ -const isTemplateLiteral = ( - node: TSESTree.Node, - value?: V, -): node is TemplateLiteral => - node.type === AST_NODE_TYPES.TemplateLiteral && - node.quasis.length === 1 && // bail out if not simple - (value === undefined || node.quasis[0].value.raw === value); - -export type StringNode = - | StringLiteral - | TemplateLiteral; - -/** - * Checks if the given `node` is a {@link StringNode}. - * - * @param {Node} node - * @param {V} [specifics] - * - * @return {node is StringNode} - * - * @template V - */ -export const isStringNode = ( - node: TSESTree.Node, - specifics?: V, -): node is StringNode => - isStringLiteral(node, specifics) || isTemplateLiteral(node, specifics); - -/** - * Gets the value of the given `StringNode`. - * - * If the `node` is a `TemplateLiteral`, the `raw` value is used; - * otherwise, `value` is returned instead. - * - * @param {StringNode} node - * - * @return {S} - * - * @template S - */ -export const getStringValue = (node: StringNode): S => - isTemplateLiteral(node) ? node.quasis[0].value.raw : node.value; - -/** - * Represents a `MemberExpression` with a "known" `property`. - */ -interface KnownMemberExpression - extends TSESTree.MemberExpressionComputedName { - property: AccessorNode; -} - -/** - * Represents a `CallExpression` with a "known" `property` accessor. - * - * i.e `KnownCallExpression<'includes'>` represents `.includes()`. - */ -export interface KnownCallExpression - extends TSESTree.CallExpression { - callee: CalledKnownMemberExpression; -} - -/** - * Represents a `MemberExpression` with a "known" `property`, that is called. - * - * This is `KnownCallExpression` from the perspective of the `MemberExpression` node. - */ -export interface CalledKnownMemberExpression - extends KnownMemberExpression { - parent: KnownCallExpression; -} - -/** - * Represents a `CallExpression` with a single argument. - */ -export interface CallExpressionWithSingleArgument< - Argument extends TSESTree.Expression = TSESTree.Expression, -> extends TSESTree.CallExpression { - arguments: [Argument]; -} - -/** - * Guards that the given `call` has only one `argument`. - * - * @param {CallExpression} call - * - * @return {call is CallExpressionWithSingleArgument} - */ -export const hasOnlyOneArgument = ( - call: TSESTree.CallExpression, -): call is CallExpressionWithSingleArgument => call.arguments.length === 1; - -/** - * An `Identifier` with a known `name` value - i.e `expect`. - */ -interface KnownIdentifier extends TSESTree.Identifier { - name: Name; -} - -/** - * Checks if the given `node` is an `Identifier`. - * - * If a `name` is provided, & the `node` is an `Identifier`, - * the `name` will be compared to that of the `identifier`. - * - * @param {Node} node - * @param {V} [name] - * - * @return {node is KnownIdentifier} - * - * @template V - */ -export const isIdentifier = ( - node: TSESTree.Node, - name?: V, -): node is KnownIdentifier => - node.type === AST_NODE_TYPES.Identifier && - (name === undefined || node.name === name); - -/** - * Checks if the given `node` is a "supported accessor". - * - * This means that it's a node can be used to access properties, - * and who's "value" can be statically determined. - * - * `MemberExpression` nodes most commonly contain accessors, - * but it's possible for other nodes to contain them. - * - * If a `value` is provided & the `node` is an `AccessorNode`, - * the `value` will be compared to that of the `AccessorNode`. - * - * Note that `value` here refers to the normalised value. - * The property that holds the value is not always called `name`. - * - * @param {Node} node - * @param {V} [value] - * - * @return {node is AccessorNode} - * - * @template V - */ -export const isSupportedAccessor = ( - node: TSESTree.Node, - value?: V, -): node is AccessorNode => - isIdentifier(node, value) || isStringNode(node, value); - -/** - * Gets the value of the given `AccessorNode`, - * account for the different node types. - * - * @param {AccessorNode} accessor - * - * @return {S} - * - * @template S - */ -export const getAccessorValue = ( - accessor: AccessorNode, -): S => - accessor.type === AST_NODE_TYPES.Identifier - ? accessor.name - : getStringValue(accessor); - -export type AccessorNode = - | StringNode - | KnownIdentifier; - -interface ExpectCall extends TSESTree.CallExpression { - callee: AccessorNode<'expect'>; - parent: TSESTree.Node; -} - -/** - * Checks if the given `node` is a valid `ExpectCall`. - * - * In order to be an `ExpectCall`, the `node` must: - * * be a `CallExpression`, - * * have an accessor named 'expect', - * * have a `parent`. - * - * @param {Node} node - * - * @return {node is ExpectCall} - */ -export const isExpectCall = (node: TSESTree.Node): node is ExpectCall => - node.type === AST_NODE_TYPES.CallExpression && - isSupportedAccessor(node.callee, 'expect') && - node.parent !== undefined; - -interface ParsedExpectMember< - Name extends ExpectPropertyName = ExpectPropertyName, - Node extends ExpectMember = ExpectMember, -> { - name: Name; - node: Node; -} - -/** - * Represents a `MemberExpression` that comes after an `ExpectCall`. - */ -interface ExpectMember< - PropertyName extends ExpectPropertyName = ExpectPropertyName, -> extends KnownMemberExpression { - object: ExpectCall | ExpectMember; - parent: TSESTree.Node; -} - -export const isExpectMember = < - Name extends ExpectPropertyName = ExpectPropertyName, ->( - node: TSESTree.Node, - name?: Name, -): node is ExpectMember => - node.type === AST_NODE_TYPES.MemberExpression && - isSupportedAccessor(node.property, name); - -/** - * Represents all the jest matchers. - */ -type MatcherName = string /* & not ModifierName */; -type ExpectPropertyName = ModifierName | MatcherName; - -export type ParsedEqualityMatcherCall< - Argument extends TSESTree.Expression = TSESTree.Expression, - Matcher extends EqualityMatcher = EqualityMatcher, -> = Omit, 'arguments'> & { - parent: TSESTree.CallExpression; - arguments: [Argument]; -}; - -export enum ModifierName { - not = 'not', - rejects = 'rejects', - resolves = 'resolves', -} - -export enum EqualityMatcher { - toBe = 'toBe', - toEqual = 'toEqual', - toStrictEqual = 'toStrictEqual', -} - -export const isParsedEqualityMatcherCall = < - MatcherName extends EqualityMatcher = EqualityMatcher, ->( - matcher: ParsedExpectMatcher, - name?: MatcherName, -): matcher is ParsedEqualityMatcherCall => - (name - ? matcher.name === name - : EqualityMatcher.hasOwnProperty(matcher.name)) && - matcher.arguments !== null && - matcher.arguments.length === 1; - -/** - * Represents a parsed expect matcher, such as `toBe`, `toContain`, and so on. - */ -export interface ParsedExpectMatcher< - Matcher extends MatcherName = MatcherName, - Node extends ExpectMember = ExpectMember, -> extends ParsedExpectMember { - /** - * The arguments being passed to the matcher. - * A value of `null` means the matcher isn't being called. - */ - arguments: TSESTree.CallExpression['arguments'] | null; -} - -type BaseParsedModifier = - ParsedExpectMember; - -type NegatableModifierName = ModifierName.rejects | ModifierName.resolves; -type NotNegatableModifierName = ModifierName.not; - -/** - * Represents a parsed modifier that can be followed by a `not` negation modifier. - */ -interface NegatableParsedModifier< - Modifier extends NegatableModifierName = NegatableModifierName, -> extends BaseParsedModifier { - negation?: ExpectMember; -} - -/** - * Represents a parsed modifier that cannot be followed by a `not` negation modifier. - */ -export interface NotNegatableParsedModifier< - Modifier extends NotNegatableModifierName = NotNegatableModifierName, -> extends BaseParsedModifier { - negation?: never; -} - -export type ParsedExpectModifier = - | NotNegatableParsedModifier - | NegatableParsedModifier; - -interface Expectation { - expect: ExpectNode; - modifier?: ParsedExpectModifier; - matcher?: ParsedExpectMatcher; -} - -const parseExpectMember = ( - expectMember: ExpectMember, -): ParsedExpectMember => ({ - name: getAccessorValue(expectMember.property), - node: expectMember, -}); - -const reparseAsMatcher = ( - parsedMember: ParsedExpectMember, -): ParsedExpectMatcher => ({ - ...parsedMember, - /** - * The arguments being passed to this `Matcher`, if any. - * - * If this matcher isn't called, this will be `null`. - */ - arguments: - parsedMember.node.parent.type === AST_NODE_TYPES.CallExpression - ? parsedMember.node.parent.arguments - : null, -}); - -/** - * Re-parses the given `parsedMember` as a `ParsedExpectModifier`. - * - * If the given `parsedMember` does not have a `name` of a valid `Modifier`, - * an exception will be thrown. - * - * @param {ParsedExpectMember} parsedMember - * - * @return {ParsedExpectModifier} - */ -const reparseMemberAsModifier = ( - parsedMember: ParsedExpectMember, -): ParsedExpectModifier => { - if (isSpecificMember(parsedMember, ModifierName.not)) { - return parsedMember; - } - - /* istanbul ignore if */ - if ( - !isSpecificMember(parsedMember, ModifierName.resolves) && - !isSpecificMember(parsedMember, ModifierName.rejects) - ) { - // ts doesn't think that the ModifierName.not check is the direct inverse as the above two checks - // todo: impossible at runtime, but can't be typed w/o negation support - throw new Error( - `modifier name must be either "${ModifierName.resolves}" or "${ModifierName.rejects}" (got "${parsedMember.name}")`, - ); - } - - const negation = isExpectMember(parsedMember.node.parent, ModifierName.not) - ? parsedMember.node.parent - : undefined; - - return { - ...parsedMember, - negation, - }; -}; - -const isSpecificMember = ( - member: ParsedExpectMember, - specific: Name, -): member is ParsedExpectMember => member.name === specific; - -/** - * Checks if the given `ParsedExpectMember` should be re-parsed as an `ParsedExpectModifier`. - * - * @param {ParsedExpectMember} member - * - * @return {member is ParsedExpectMember} - */ -const shouldBeParsedExpectModifier = ( - member: ParsedExpectMember, -): member is ParsedExpectMember => - ModifierName.hasOwnProperty(member.name); - -export const parseExpectCall = ( - expect: ExpectNode, -): Expectation => { - const expectation: Expectation = { - expect, - }; - - if (!isExpectMember(expect.parent)) { - return expectation; - } - - const parsedMember = parseExpectMember(expect.parent); - - if (!shouldBeParsedExpectModifier(parsedMember)) { - expectation.matcher = reparseAsMatcher(parsedMember); - - return expectation; - } - - const modifier = (expectation.modifier = - reparseMemberAsModifier(parsedMember)); - - const memberNode = modifier.negation || modifier.node; - - if (!isExpectMember(memberNode.parent)) { - return expectation; - } - - expectation.matcher = reparseAsMatcher(parseExpectMember(memberNode.parent)); - - return expectation; -}; - -export enum DescribeAlias { - 'describe' = 'describe', - 'fdescribe' = 'fdescribe', - 'xdescribe' = 'xdescribe', -} - -export enum TestCaseName { - 'fit' = 'fit', - 'it' = 'it', - 'test' = 'test', - 'xit' = 'xit', - 'xtest' = 'xtest', -} - -export enum HookName { - 'beforeAll' = 'beforeAll', - 'beforeEach' = 'beforeEach', - 'afterAll' = 'afterAll', - 'afterEach' = 'afterEach', -} - -export enum DescribeProperty { - 'each' = 'each', - 'only' = 'only', - 'skip' = 'skip', -} - -export enum TestCaseProperty { - 'each' = 'each', - 'concurrent' = 'concurrent', - 'only' = 'only', - 'skip' = 'skip', - 'todo' = 'todo', -} - -type JestFunctionName = DescribeAlias | TestCaseName | HookName; -type JestPropertyName = DescribeProperty | TestCaseProperty; - -interface JestFunctionIdentifier - extends TSESTree.Identifier { - name: FunctionName; -} - -interface JestFunctionMemberExpression< - FunctionName extends JestFunctionName, - PropertyName extends JestPropertyName = JestPropertyName, -> extends KnownMemberExpression { - object: JestFunctionIdentifier; -} - -interface JestFunctionCallExpressionWithMemberExpressionCallee< - FunctionName extends JestFunctionName, - PropertyName extends JestPropertyName = JestPropertyName, -> extends TSESTree.CallExpression { - callee: JestFunctionMemberExpression; -} - -export interface JestFunctionCallExpressionWithIdentifierCallee< - FunctionName extends JestFunctionName, -> extends TSESTree.CallExpression { - callee: JestFunctionIdentifier; -} - -interface JestEachMemberExpression< - TName extends Exclude, -> extends KnownMemberExpression<'each'> { - object: - | KnownIdentifier - | (KnownMemberExpression & { object: KnownIdentifier }); -} - -export interface JestCalledEachCallExpression< - TName extends Exclude, -> extends TSESTree.CallExpression { - callee: TSESTree.CallExpression & { - callee: JestEachMemberExpression; - }; -} - -export interface JestTaggedEachCallExpression< - TName extends Exclude, -> extends TSESTree.CallExpression { - callee: TSESTree.TaggedTemplateExpression & { - tag: JestEachMemberExpression; - }; -} - -type JestEachCallExpression> = - JestCalledEachCallExpression | JestTaggedEachCallExpression; - -export type JestFunctionCallExpression< - FunctionName extends Exclude = Exclude< - JestFunctionName, - HookName - >, -> = - | JestEachCallExpression - | JestFunctionCallExpressionWithMemberExpressionCallee - | JestFunctionCallExpressionWithIdentifierCallee; - -const joinNames = (a: string | null, b: string | null): string | null => - a && b ? `${a}.${b}` : null; - -export function getNodeName( - node: - | JestFunctionCallExpression - | JestFunctionMemberExpression - | JestFunctionIdentifier - | TSESTree.TaggedTemplateExpression, -): string; -export function getNodeName(node: TSESTree.Node): string | null; -export function getNodeName(node: TSESTree.Node): string | null { - if (isSupportedAccessor(node)) { - return getAccessorValue(node); - } - - switch (node.type) { - case AST_NODE_TYPES.TaggedTemplateExpression: - return getNodeName(node.tag); - case AST_NODE_TYPES.MemberExpression: - return joinNames(getNodeName(node.object), getNodeName(node.property)); - case AST_NODE_TYPES.NewExpression: - case AST_NODE_TYPES.CallExpression: - return getNodeName(node.callee); - } - - return null; -} - -export type FunctionExpression = - | TSESTree.ArrowFunctionExpression - | TSESTree.FunctionExpression; - -export const isFunction = (node: TSESTree.Node): node is FunctionExpression => - node.type === AST_NODE_TYPES.FunctionExpression || - node.type === AST_NODE_TYPES.ArrowFunctionExpression; - -export const getTestCallExpressionsFromDeclaredVariables = ( - declaredVariables: readonly TSESLint.Scope.Variable[], - scope: TSESLint.Scope.Scope, -): Array> => { - return declaredVariables.reduce< - Array> - >( - (acc, { references }) => - acc.concat( - references - .map(({ identifier }) => identifier.parent) - .filter( - (node): node is JestFunctionCallExpression => - !!node && - node.type === AST_NODE_TYPES.CallExpression && - isTypeOfJestFnCall(node, scope, ['test']), - ), - ), - [], - ); -}; - -export * from './utils/parseJestFnCall'; diff --git a/src/rules/__tests__/detectJestVersion.test.ts b/src/rules/utils/__tests__/detectJestVersion.test.ts similarity index 100% rename from src/rules/__tests__/detectJestVersion.test.ts rename to src/rules/utils/__tests__/detectJestVersion.test.ts diff --git a/src/rules/utils/accessors.ts b/src/rules/utils/accessors.ts new file mode 100644 index 000000000..c7b7f4e5e --- /dev/null +++ b/src/rules/utils/accessors.ts @@ -0,0 +1,169 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; + +/** + * A `Literal` with a `value` of type `string`. + */ +interface StringLiteral + extends TSESTree.StringLiteral { + value: Value; +} + +/** + * Checks if the given `node` is a `StringLiteral`. + * + * If a `value` is provided & the `node` is a `StringLiteral`, + * the `value` will be compared to that of the `StringLiteral`. + * + * @param {Node} node + * @param {V} [value] + * + * @return {node is StringLiteral} + * + * @template V + */ +const isStringLiteral = ( + node: TSESTree.Node, + value?: V, +): node is StringLiteral => + node.type === AST_NODE_TYPES.Literal && + typeof node.value === 'string' && + (value === undefined || node.value === value); + +interface TemplateLiteral + extends TSESTree.TemplateLiteral { + quasis: [TSESTree.TemplateElement & { value: { raw: Value; cooked: Value } }]; +} + +/** + * Checks if the given `node` is a `TemplateLiteral`. + * + * Complex `TemplateLiteral`s are not considered specific, and so will return `false`. + * + * If a `value` is provided & the `node` is a `TemplateLiteral`, + * the `value` will be compared to that of the `TemplateLiteral`. + * + * @param {Node} node + * @param {V} [value] + * + * @return {node is TemplateLiteral} + * + * @template V + */ +const isTemplateLiteral = ( + node: TSESTree.Node, + value?: V, +): node is TemplateLiteral => + node.type === AST_NODE_TYPES.TemplateLiteral && + node.quasis.length === 1 && // bail out if not simple + (value === undefined || node.quasis[0].value.raw === value); + +export type StringNode = + | StringLiteral + | TemplateLiteral; + +/** + * Checks if the given `node` is a {@link StringNode}. + * + * @param {Node} node + * @param {V} [specifics] + * + * @return {node is StringNode} + * + * @template V + */ +export const isStringNode = ( + node: TSESTree.Node, + specifics?: V, +): node is StringNode => + isStringLiteral(node, specifics) || isTemplateLiteral(node, specifics); + +/** + * Gets the value of the given `StringNode`. + * + * If the `node` is a `TemplateLiteral`, the `raw` value is used; + * otherwise, `value` is returned instead. + * + * @param {StringNode} node + * + * @return {S} + * + * @template S + */ +export const getStringValue = (node: StringNode): S => + isTemplateLiteral(node) ? node.quasis[0].value.raw : node.value; + +/** + * An `Identifier` with a known `name` value - i.e `expect`. + */ +interface KnownIdentifier extends TSESTree.Identifier { + name: Name; +} + +/** + * Checks if the given `node` is an `Identifier`. + * + * If a `name` is provided, & the `node` is an `Identifier`, + * the `name` will be compared to that of the `identifier`. + * + * @param {Node} node + * @param {V} [name] + * + * @return {node is KnownIdentifier} + * + * @template V + */ +export const isIdentifier = ( + node: TSESTree.Node, + name?: V, +): node is KnownIdentifier => + node.type === AST_NODE_TYPES.Identifier && + (name === undefined || node.name === name); + +/** + * Checks if the given `node` is a "supported accessor". + * + * This means that it's a node can be used to access properties, + * and who's "value" can be statically determined. + * + * `MemberExpression` nodes most commonly contain accessors, + * but it's possible for other nodes to contain them. + * + * If a `value` is provided & the `node` is an `AccessorNode`, + * the `value` will be compared to that of the `AccessorNode`. + * + * Note that `value` here refers to the normalised value. + * The property that holds the value is not always called `name`. + * + * @param {Node} node + * @param {V} [value] + * + * @return {node is AccessorNode} + * + * @template V + */ +export const isSupportedAccessor = ( + node: TSESTree.Node, + value?: V, +): node is AccessorNode => + isIdentifier(node, value) || isStringNode(node, value); + +/** + * Gets the value of the given `AccessorNode`, + * account for the different node types. + * + * @param {AccessorNode} accessor + * + * @return {S} + * + * @template S + */ +export const getAccessorValue = ( + accessor: AccessorNode, +): S => + accessor.type === AST_NODE_TYPES.Identifier + ? accessor.name + : getStringValue(accessor); + +export type AccessorNode = + | StringNode + | KnownIdentifier; diff --git a/src/rules/detectJestVersion.ts b/src/rules/utils/detectJestVersion.ts similarity index 100% rename from src/rules/detectJestVersion.ts rename to src/rules/utils/detectJestVersion.ts diff --git a/src/rules/utils/followTypeAssertionChain.ts b/src/rules/utils/followTypeAssertionChain.ts new file mode 100644 index 000000000..1e3620a6c --- /dev/null +++ b/src/rules/utils/followTypeAssertionChain.ts @@ -0,0 +1,36 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; + +export type MaybeTypeCast = + | TSTypeCastExpression + | Expression; + +type TSTypeCastExpression< + Expression extends TSESTree.Expression = TSESTree.Expression, +> = AsExpressionChain | TypeAssertionChain; + +interface AsExpressionChain< + Expression extends TSESTree.Expression = TSESTree.Expression, +> extends TSESTree.TSAsExpression { + expression: AsExpressionChain | Expression; +} + +interface TypeAssertionChain< + Expression extends TSESTree.Expression = TSESTree.Expression, +> extends TSESTree.TSTypeAssertion { + expression: TypeAssertionChain | Expression; +} + +const isTypeCastExpression = ( + node: MaybeTypeCast, +): node is TSTypeCastExpression => + node.type === AST_NODE_TYPES.TSAsExpression || + node.type === AST_NODE_TYPES.TSTypeAssertion; + +export const followTypeAssertionChain = < + Expression extends TSESTree.Expression, +>( + expression: MaybeTypeCast, +): Expression => + isTypeCastExpression(expression) + ? followTypeAssertionChain(expression.expression) + : expression; diff --git a/src/rules/utils/index.ts b/src/rules/utils/index.ts new file mode 100644 index 000000000..8ef0baa06 --- /dev/null +++ b/src/rules/utils/index.ts @@ -0,0 +1,6 @@ +export * from './accessors'; +export * from './detectJestVersion'; +export * from './followTypeAssertionChain'; +export * from './misc'; +export * from './parseJestFnCall'; +export * from './parseExpectCall'; diff --git a/src/rules/utils/misc.ts b/src/rules/utils/misc.ts new file mode 100644 index 000000000..50646da3e --- /dev/null +++ b/src/rules/utils/misc.ts @@ -0,0 +1,139 @@ +import { parse as parsePath } from 'path'; +import { + AST_NODE_TYPES, + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; +import { version } from '../../../package.json'; +import { + AccessorNode, + getAccessorValue, + isSupportedAccessor, +} from './accessors'; +import { isTypeOfJestFnCall } from './parseJestFnCall'; + +const REPO_URL = 'https://github.com/jest-community/eslint-plugin-jest'; + +export const createRule = ESLintUtils.RuleCreator(name => { + const ruleName = parsePath(name).name; + + return `${REPO_URL}/blob/v${version}/docs/rules/${ruleName}.md`; +}); + +/** + * Represents a `MemberExpression` with a "known" `property`. + */ +export interface KnownMemberExpression + extends TSESTree.MemberExpressionComputedName { + property: AccessorNode; +} + +/** + * Represents a `CallExpression` with a "known" `property` accessor. + * + * i.e `KnownCallExpression<'includes'>` represents `.includes()`. + */ +export interface KnownCallExpression + extends TSESTree.CallExpression { + callee: CalledKnownMemberExpression; +} + +/** + * Represents a `MemberExpression` with a "known" `property`, that is called. + * + * This is `KnownCallExpression` from the perspective of the `MemberExpression` node. + */ +interface CalledKnownMemberExpression + extends KnownMemberExpression { + parent: KnownCallExpression; +} + +/** + * Represents a `CallExpression` with a single argument. + */ +export interface CallExpressionWithSingleArgument< + Argument extends TSESTree.Expression = TSESTree.Expression, +> extends TSESTree.CallExpression { + arguments: [Argument]; +} + +/** + * Guards that the given `call` has only one `argument`. + * + * @param {CallExpression} call + * + * @return {call is CallExpressionWithSingleArgument} + */ +export const hasOnlyOneArgument = ( + call: TSESTree.CallExpression, +): call is CallExpressionWithSingleArgument => call.arguments.length === 1; + +export enum DescribeAlias { + 'describe' = 'describe', + 'fdescribe' = 'fdescribe', + 'xdescribe' = 'xdescribe', +} + +export enum TestCaseName { + 'fit' = 'fit', + 'it' = 'it', + 'test' = 'test', + 'xit' = 'xit', + 'xtest' = 'xtest', +} + +export enum HookName { + 'beforeAll' = 'beforeAll', + 'beforeEach' = 'beforeEach', + 'afterAll' = 'afterAll', + 'afterEach' = 'afterEach', +} + +const joinNames = (a: string | null, b: string | null): string | null => + a && b ? `${a}.${b}` : null; + +export function getNodeName(node: TSESTree.Node): string | null { + if (isSupportedAccessor(node)) { + return getAccessorValue(node); + } + + switch (node.type) { + case AST_NODE_TYPES.TaggedTemplateExpression: + return getNodeName(node.tag); + case AST_NODE_TYPES.MemberExpression: + return joinNames(getNodeName(node.object), getNodeName(node.property)); + case AST_NODE_TYPES.NewExpression: + case AST_NODE_TYPES.CallExpression: + return getNodeName(node.callee); + } + + return null; +} + +export type FunctionExpression = + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionExpression; + +export const isFunction = (node: TSESTree.Node): node is FunctionExpression => + node.type === AST_NODE_TYPES.FunctionExpression || + node.type === AST_NODE_TYPES.ArrowFunctionExpression; + +export const getTestCallExpressionsFromDeclaredVariables = ( + declaredVariables: readonly TSESLint.Scope.Variable[], + scope: TSESLint.Scope.Scope, +): TSESTree.CallExpression[] => { + return declaredVariables.reduce( + (acc, { references }) => + acc.concat( + references + .map(({ identifier }) => identifier.parent) + .filter( + (node): node is TSESTree.CallExpression => + node?.type === AST_NODE_TYPES.CallExpression && + isTypeOfJestFnCall(node, scope, ['test']), + ), + ), + [], + ); +}; diff --git a/src/rules/utils/parseExpectCall.ts b/src/rules/utils/parseExpectCall.ts new file mode 100644 index 000000000..e5b55f51d --- /dev/null +++ b/src/rules/utils/parseExpectCall.ts @@ -0,0 +1,253 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import { + AccessorNode, + KnownMemberExpression, + getAccessorValue, + isSupportedAccessor, +} from '../utils'; + +interface ExpectCall extends TSESTree.CallExpression { + callee: AccessorNode<'expect'>; + parent: TSESTree.Node; +} + +/** + * Checks if the given `node` is a valid `ExpectCall`. + * + * In order to be an `ExpectCall`, the `node` must: + * * be a `CallExpression`, + * * have an accessor named 'expect', + * * have a `parent`. + * + * @param {Node} node + * + * @return {node is ExpectCall} + */ +export const isExpectCall = (node: TSESTree.Node): node is ExpectCall => + node.type === AST_NODE_TYPES.CallExpression && + isSupportedAccessor(node.callee, 'expect') && + node.parent !== undefined; + +interface ParsedExpectMember< + Name extends ExpectPropertyName = ExpectPropertyName, + Node extends ExpectMember = ExpectMember, +> { + name: Name; + node: Node; +} + +/** + * Represents a `MemberExpression` that comes after an `ExpectCall`. + */ +interface ExpectMember< + PropertyName extends ExpectPropertyName = ExpectPropertyName, +> extends KnownMemberExpression { + object: ExpectCall | ExpectMember; + parent: TSESTree.Node; +} + +export const isExpectMember = < + Name extends ExpectPropertyName = ExpectPropertyName, +>( + node: TSESTree.Node, + name?: Name, +): node is ExpectMember => + node.type === AST_NODE_TYPES.MemberExpression && + isSupportedAccessor(node.property, name); + +/** + * Represents all the jest matchers. + */ +type MatcherName = string /* & not ModifierName */; +type ExpectPropertyName = ModifierName | MatcherName; + +export type ParsedEqualityMatcherCall< + Argument extends TSESTree.Expression = TSESTree.Expression, + Matcher extends EqualityMatcher = EqualityMatcher, +> = Omit, 'arguments'> & { + parent: TSESTree.CallExpression; + arguments: [Argument]; +}; + +export enum ModifierName { + not = 'not', + rejects = 'rejects', + resolves = 'resolves', +} + +export enum EqualityMatcher { + toBe = 'toBe', + toEqual = 'toEqual', + toStrictEqual = 'toStrictEqual', +} + +export const isParsedEqualityMatcherCall = < + MatcherName extends EqualityMatcher = EqualityMatcher, +>( + matcher: ParsedExpectMatcher, + name?: MatcherName, +): matcher is ParsedEqualityMatcherCall => + (name + ? matcher.name === name + : EqualityMatcher.hasOwnProperty(matcher.name)) && + matcher.arguments !== null && + matcher.arguments.length === 1; + +/** + * Represents a parsed expect matcher, such as `toBe`, `toContain`, and so on. + */ +export interface ParsedExpectMatcher< + Matcher extends MatcherName = MatcherName, + Node extends ExpectMember = ExpectMember, +> extends ParsedExpectMember { + /** + * The arguments being passed to the matcher. + * A value of `null` means the matcher isn't being called. + */ + arguments: TSESTree.CallExpression['arguments'] | null; +} + +type BaseParsedModifier = + ParsedExpectMember; + +type NegatableModifierName = ModifierName.rejects | ModifierName.resolves; +type NotNegatableModifierName = ModifierName.not; + +/** + * Represents a parsed modifier that can be followed by a `not` negation modifier. + */ +interface NegatableParsedModifier< + Modifier extends NegatableModifierName = NegatableModifierName, +> extends BaseParsedModifier { + negation?: ExpectMember; +} + +/** + * Represents a parsed modifier that cannot be followed by a `not` negation modifier. + */ +export interface NotNegatableParsedModifier< + Modifier extends NotNegatableModifierName = NotNegatableModifierName, +> extends BaseParsedModifier { + negation?: never; +} + +export type ParsedExpectModifier = + | NotNegatableParsedModifier + | NegatableParsedModifier; + +interface Expectation { + expect: ExpectNode; + modifier?: ParsedExpectModifier; + matcher?: ParsedExpectMatcher; +} + +const parseExpectMember = ( + expectMember: ExpectMember, +): ParsedExpectMember => ({ + name: getAccessorValue(expectMember.property), + node: expectMember, +}); + +const reparseAsMatcher = ( + parsedMember: ParsedExpectMember, +): ParsedExpectMatcher => ({ + ...parsedMember, + /** + * The arguments being passed to this `Matcher`, if any. + * + * If this matcher isn't called, this will be `null`. + */ + arguments: + parsedMember.node.parent.type === AST_NODE_TYPES.CallExpression + ? parsedMember.node.parent.arguments + : null, +}); + +/** + * Re-parses the given `parsedMember` as a `ParsedExpectModifier`. + * + * If the given `parsedMember` does not have a `name` of a valid `Modifier`, + * an exception will be thrown. + * + * @param {ParsedExpectMember} parsedMember + * + * @return {ParsedExpectModifier} + */ +const reparseMemberAsModifier = ( + parsedMember: ParsedExpectMember, +): ParsedExpectModifier => { + if (isSpecificMember(parsedMember, ModifierName.not)) { + return parsedMember; + } + + /* istanbul ignore if */ + if ( + !isSpecificMember(parsedMember, ModifierName.resolves) && + !isSpecificMember(parsedMember, ModifierName.rejects) + ) { + // ts doesn't think that the ModifierName.not check is the direct inverse as the above two checks + // todo: impossible at runtime, but can't be typed w/o negation support + throw new Error( + `modifier name must be either "${ModifierName.resolves}" or "${ModifierName.rejects}" (got "${parsedMember.name}")`, + ); + } + + const negation = isExpectMember(parsedMember.node.parent, ModifierName.not) + ? parsedMember.node.parent + : undefined; + + return { + ...parsedMember, + negation, + }; +}; + +const isSpecificMember = ( + member: ParsedExpectMember, + specific: Name, +): member is ParsedExpectMember => member.name === specific; + +/** + * Checks if the given `ParsedExpectMember` should be re-parsed as an `ParsedExpectModifier`. + * + * @param {ParsedExpectMember} member + * + * @return {member is ParsedExpectMember} + */ +const shouldBeParsedExpectModifier = ( + member: ParsedExpectMember, +): member is ParsedExpectMember => + ModifierName.hasOwnProperty(member.name); + +export const parseExpectCall = ( + expect: ExpectNode, +): Expectation => { + const expectation: Expectation = { + expect, + }; + + if (!isExpectMember(expect.parent)) { + return expectation; + } + + const parsedMember = parseExpectMember(expect.parent); + + if (!shouldBeParsedExpectModifier(parsedMember)) { + expectation.matcher = reparseAsMatcher(parsedMember); + + return expectation; + } + + const modifier = (expectation.modifier = + reparseMemberAsModifier(parsedMember)); + + const memberNode = modifier.negation || modifier.node; + + if (!isExpectMember(memberNode.parent)) { + return expectation; + } + + expectation.matcher = reparseAsMatcher(parseExpectMember(memberNode.parent)); + + return expectation; +};