UNPKG

eslint-plugin-ava

Version:
482 lines (427 loc) 11.8 kB
import {visitIf} from 'enhance-visitors'; import { getStaticValue, isOpeningParenToken, isCommaToken, findVariable, } from '@eslint-community/eslint-utils'; import util from '../util.js'; import createAvaRule from '../create-ava-rule.js'; const MESSAGE_ID_TOO_FEW = 'too-few-arguments'; const MESSAGE_ID_TOO_MANY = 'too-many-arguments'; const MESSAGE_ID_MISSING_MESSAGE = 'missing-message'; const MESSAGE_ID_FOUND_MESSAGE = 'found-message'; const MESSAGE_ID_NOT_STRING = 'not-string-message'; const MESSAGE_ID_OUT_OF_ORDER = 'out-of-order'; const MESSAGE_ID_PLAN_NOT_INTEGER = 'plan-not-integer'; const MESSAGE_ID_REGEX_FIRST = 'regex-first-argument'; const expectedNbArguments = { assert: { min: 1, max: 2, }, deepEqual: { min: 2, max: 3, }, fail: { min: 0, max: 1, }, false: { min: 1, max: 2, }, falsy: { min: 1, max: 2, }, ifError: { min: 1, max: 2, }, is: { min: 2, max: 3, }, like: { min: 2, max: 3, }, not: { min: 2, max: 3, }, notDeepEqual: { min: 2, max: 3, }, notThrows: { min: 1, max: 2, }, notThrowsAsync: { min: 1, max: 2, }, pass: { min: 0, max: 1, }, plan: { min: 1, max: 1, }, regex: { min: 2, max: 3, }, notRegex: { min: 2, max: 3, }, snapshot: { min: 1, max: 2, }, teardown: { min: 1, max: 1, }, throws: { min: 1, max: 3, }, throwsAsync: { min: 1, max: 3, }, true: { min: 1, max: 2, }, truthy: { min: 1, max: 2, }, timeout: { min: 1, max: 2, }, }; const actualExpectedAssertions = new Set([ 'deepEqual', 'is', 'like', 'not', 'notDeepEqual', 'throws', 'throwsAsync', ]); const relationalActualExpectedAssertions = new Set([ 'assert', 'truthy', 'falsy', 'true', 'false', ]); const comparisonOperators = new Map([ ['>', '<'], ['>=', '<='], ['==', '=='], ['===', '==='], ['!=', '!='], ['!==', '!=='], ['<=', '>='], ['<', '>'], ]); const flipOperator = operator => comparisonOperators.get(operator); function isStatic(node) { const staticValue = getStaticValue(node); return staticValue !== null && typeof staticValue.value !== 'function'; } function * sourceRangesOfArguments(sourceCode, callExpression) { const openingParen = sourceCode.getTokenAfter( callExpression.callee, {filter: token => isOpeningParenToken(token)}, ); const closingParen = sourceCode.getLastToken(callExpression); for (const [index, argument] of callExpression.arguments.entries()) { const previousToken = index === 0 ? openingParen : sourceCode.getTokenBefore( argument, {filter: token => isCommaToken(token)}, ); const nextToken = index === callExpression.arguments.length - 1 ? closingParen : sourceCode.getTokenAfter( argument, {filter: token => isCommaToken(token)}, ); const firstToken = sourceCode.getTokenAfter( previousToken, {includeComments: true}, ); const lastToken = sourceCode.getTokenBefore( nextToken, {includeComments: true}, ); yield [firstToken.range[0], lastToken.range[1]]; } /* c8 ignore next -- only called for nodes with 2+ arguments, so the loop always iterates */ } function sourceOfBinaryExpressionComponents(sourceCode, node) { const {operator, left, right} = node; const operatorToken = sourceCode.getFirstTokenBetween( left, right, {filter: token => token.value === operator}, ); const previousToken = sourceCode.getTokenBefore(node); const nextToken = sourceCode.getTokenAfter(node); const leftRange = [ sourceCode.getTokenAfter(previousToken, {includeComments: true}).range[0], sourceCode.getTokenBefore(operatorToken, {includeComments: true}).range[1], ]; const rightRange = [ sourceCode.getTokenAfter(operatorToken, {includeComments: true}).range[0], sourceCode.getTokenBefore(nextToken, {includeComments: true}).range[1], ]; return [leftRange, operatorToken, rightRange]; } function noComments(sourceCode, ...nodes) { return nodes.every(node => sourceCode.getCommentsBefore(node).length === 0 && sourceCode.getCommentsAfter(node).length === 0); } function isRegex(node) { const {type} = node; return (type === 'Literal' && node.regex) || ((type === 'NewExpression' || type === 'CallExpression') && node.callee.type === 'Identifier' && node.callee.name === 'RegExp'); } function isString(node) { const {type} = node; return type === 'TemplateLiteral' || type === 'TaggedTemplateExpression' || (type === 'Literal' && typeof node.value === 'string') || (type === 'BinaryExpression' && node.operator === '+' && (isString(node.left) || isString(node.right))); } const create = context => { const ava = createAvaRule(); const options = context.options[0]; const enforcesMessage = Boolean(options.message); const shouldHaveMessage = options.message !== 'never'; return ava.merge({ CallExpression: visitIf([ ava.isInTestFile, ava.isInTestNode, ])(node => { const {callee} = node; if ( callee.type !== 'MemberExpression' || !callee.property || !util.isTestObject(util.getNameOfRootNodeObject(callee)) || util.isPropertyUnderContext(callee) ) { return; } const gottenArguments = node.arguments.length; const firstNonSkipMember = util.getMembers(callee).find(name => name !== 'skip'); if (firstNonSkipMember === 'end') { if (gottenArguments > 1) { context.report({node, messageId: MESSAGE_ID_TOO_MANY, data: {max: 1}}); } return; } if (firstNonSkipMember === 'try') { if (gottenArguments < 1) { context.report({node, messageId: MESSAGE_ID_TOO_FEW, data: {min: 1}}); } return; } const nArguments = expectedNbArguments[firstNonSkipMember]; if (!nArguments) { return; } if (gottenArguments < nArguments.min) { context.report({node, messageId: MESSAGE_ID_TOO_FEW, data: {min: nArguments.min}}); } else if (node.arguments.length > nArguments.max) { context.report({node, messageId: MESSAGE_ID_TOO_MANY, data: {max: nArguments.max}}); } else { if (enforcesMessage && nArguments.min !== nArguments.max) { const hasMessage = gottenArguments === nArguments.max; if (!hasMessage && shouldHaveMessage) { context.report({node, messageId: MESSAGE_ID_MISSING_MESSAGE}); } else if (hasMessage && !shouldHaveMessage) { context.report({node, messageId: MESSAGE_ID_FOUND_MESSAGE}); } } checkArgumentOrder({node, assertion: firstNonSkipMember, context}); if (firstNonSkipMember === 'plan') { const argument = node.arguments[0]; const staticValue = getStaticValue(argument); if ( staticValue !== null && (typeof staticValue.value !== 'number' || !Number.isInteger(staticValue.value) || staticValue.value < 0) ) { context.report({node: argument, messageId: MESSAGE_ID_PLAN_NOT_INTEGER}); } } if ( (firstNonSkipMember === 'regex' || firstNonSkipMember === 'notRegex') && isRegex(node.arguments[0]) ) { const [firstArgument, secondArgument] = node.arguments; const {sourceCode} = context; const [leftRange, rightRange] = sourceRangesOfArguments(sourceCode, node); const report = { node: firstArgument, messageId: MESSAGE_ID_REGEX_FIRST, }; if (noComments(sourceCode, firstArgument, secondArgument) && !isRegex(secondArgument)) { report.fix = fixer => { const leftText = sourceCode.getText().slice(...leftRange); const rightText = sourceCode.getText().slice(...rightRange); return [ fixer.replaceTextRange(leftRange, rightText), fixer.replaceTextRange(rightRange, leftText), ]; }; } context.report(report); } } if (gottenArguments === nArguments.max && nArguments.min !== nArguments.max) { let lastArgument = node.arguments.at(-1); if (lastArgument.type === 'Identifier') { const variable = findVariable(context.sourceCode.getScope(node), lastArgument); let resolved; if (variable) { for (const reference of variable.references) { resolved = reference.writeExpr ?? resolved; } } if (resolved) { lastArgument = resolved; } else if (!/^(?:[A-Z][a-z\d]*)*Error$/.test(lastArgument.name)) { return; } } if (!isString(lastArgument)) { context.report({node, messageId: MESSAGE_ID_NOT_STRING}); } } }), }); }; function checkArgumentOrder({node, assertion, context}) { const [first, second] = node.arguments; if (actualExpectedAssertions.has(assertion) && second) { const [leftNode, rightNode] = [first, second]; if (isStatic(leftNode) && !isStatic(rightNode)) { context.report(makeOutOfOrder2ArgumentReport({ node, leftNode, rightNode, context, })); } } else if ( relationalActualExpectedAssertions.has(assertion) && first && first.type === 'BinaryExpression' && comparisonOperators.has(first.operator) ) { const [leftNode, rightNode] = [first.left, first.right]; if (isStatic(leftNode) && !isStatic(rightNode)) { context.report(makeOutOfOrder1ArgumentReport({ node: first, leftNode, rightNode, context, })); } } } function makeOutOfOrder2ArgumentReport({node, leftNode, rightNode, context}) { const {sourceCode} = context; const [leftRange, rightRange] = sourceRangesOfArguments(sourceCode, node); const report = { messageId: MESSAGE_ID_OUT_OF_ORDER, loc: { start: sourceCode.getLocFromIndex(leftRange[0]), end: sourceCode.getLocFromIndex(rightRange[1]), }, }; if (noComments(sourceCode, leftNode, rightNode)) { report.fix = fixer => { const leftText = sourceCode.getText().slice(...leftRange); const rightText = sourceCode.getText().slice(...rightRange); return [ fixer.replaceTextRange(leftRange, rightText), fixer.replaceTextRange(rightRange, leftText), ]; }; } return report; } function makeOutOfOrder1ArgumentReport({node, leftNode, rightNode, context}) { const {sourceCode} = context; const [ leftRange, operatorToken, rightRange, ] = sourceOfBinaryExpressionComponents(sourceCode, node); const report = { messageId: MESSAGE_ID_OUT_OF_ORDER, loc: { start: sourceCode.getLocFromIndex(leftRange[0]), end: sourceCode.getLocFromIndex(rightRange[1]), }, }; if (noComments(sourceCode, leftNode, rightNode, node)) { report.fix = fixer => { const leftText = sourceCode.getText().slice(...leftRange); const rightText = sourceCode.getText().slice(...rightRange); return [ fixer.replaceTextRange(leftRange, rightText), fixer.replaceText(operatorToken, flipOperator(node.operator)), fixer.replaceTextRange(rightRange, leftText), ]; }; } return report; } const schema = [{ type: 'object', properties: { message: { description: 'Whether to enforce or disallow assertion messages.', enum: [ 'always', 'never', ], }, }, additionalProperties: false, }]; export default { create, meta: { type: 'problem', docs: { description: 'Enforce passing correct arguments to assertions.', recommended: true, url: util.getDocsUrl(import.meta.filename), }, fixable: 'code', schema, defaultOptions: [{}], messages: { [MESSAGE_ID_TOO_FEW]: 'Not enough arguments. Expected at least {{min}}.', [MESSAGE_ID_TOO_MANY]: 'Too many arguments. Expected at most {{max}}.', [MESSAGE_ID_MISSING_MESSAGE]: 'Expected an assertion message, but found none.', [MESSAGE_ID_FOUND_MESSAGE]: 'Expected no assertion message, but found one.', [MESSAGE_ID_NOT_STRING]: 'Assertion message should be a string.', [MESSAGE_ID_OUT_OF_ORDER]: 'Expected values should come after actual values.', [MESSAGE_ID_PLAN_NOT_INTEGER]: 'Expected `t.plan()` argument to be a non-negative integer.', [MESSAGE_ID_REGEX_FIRST]: 'Expected first argument to be a string, not a regex.', }, }, };