eslint-plugin-ava
Version:
ESLint rules for AVA
416 lines (366 loc) • 8.93 kB
JavaScript
'use strict';
const {visitIf} = require('enhance-visitors');
const {getStaticValue, isOpeningParenToken, isCommaToken, findVariable} = require('eslint-utils');
const util = require('../util');
const createAvaRule = require('../create-ava-rule');
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]];
}
}
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 isString(node) {
const {type} = node;
return type === 'TemplateLiteral'
|| type === 'TaggedTemplateExpression'
|| (type === 'Literal' && typeof node.value === 'string');
}
const create = context => {
const ava = createAvaRule();
const options = context.options[0] ?? {};
const enforcesMessage = Boolean(options.message);
const shouldHaveMessage = options.message !== 'never';
function report(node, message) {
context.report({node, message});
}
return ava.merge({
CallExpression: visitIf([
ava.isInTestFile,
ava.isInTestNode,
])(node => {
const {callee} = node;
if (
callee.type !== 'MemberExpression'
|| !callee.property
|| util.getNameOfRootNodeObject(callee) !== 't'
|| util.isPropertyUnderContext(callee)
) {
return;
}
const gottenArguments = node.arguments.length;
const firstNonSkipMember = util.getMembers(callee).find(name => name !== 'skip');
if (firstNonSkipMember === 'end') {
if (gottenArguments > 1) {
report(node, 'Too many arguments. Expected at most 1.');
}
return;
}
if (firstNonSkipMember === 'try') {
if (gottenArguments < 1) {
report(node, 'Not enough arguments. Expected at least 1.');
}
return;
}
const nArguments = expectedNbArguments[firstNonSkipMember];
if (!nArguments) {
return;
}
if (gottenArguments < nArguments.min) {
report(node, `Not enough arguments. Expected at least ${nArguments.min}.`);
} else if (node.arguments.length > nArguments.max) {
report(node, `Too many arguments. Expected at most ${nArguments.max}.`);
} else {
if (enforcesMessage && nArguments.min !== nArguments.max) {
const hasMessage = gottenArguments === nArguments.max;
if (!hasMessage && shouldHaveMessage) {
report(node, 'Expected an assertion message, but found none.');
} else if (hasMessage && !shouldHaveMessage) {
report(node, 'Expected no assertion message, but found one.');
}
}
checkArgumentOrder({node, assertion: firstNonSkipMember, context});
}
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 value;
for (const reference of variable.references) {
value = reference.writeExpr ?? value;
}
lastArgument = value;
}
if (!isString(lastArgument)) {
report(node, 'Assertion message should be a 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.getSourceCode();
const [leftRange, rightRange] = sourceRangesOfArguments(sourceCode, node);
const report = {
message: 'Expected values should come after actual values.',
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.getSourceCode();
const [
leftRange,
operatorToken,
rightRange,
] = sourceOfBinaryExpressionComponents(sourceCode, node);
const report = {
message: 'Expected values should come after actual values.',
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: {
enum: [
'always',
'never',
],
default: undefined,
},
},
}];
module.exports = {
create,
meta: {
type: 'problem',
docs: {
description: 'Enforce passing correct arguments to assertions.',
url: util.getDocsUrl(__filename),
},
fixable: 'code',
schema,
},
};