eslint
Version:
An AST-based pattern checker for JavaScript.
495 lines (439 loc) • 12.9 kB
JavaScript
/**
* @fileoverview Rule to enforce return statements in callbacks of array's methods
* @author Toru Nagashima
*/
;
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
const astUtils = require("./utils/ast-utils");
//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------
const TARGET_NODE_TYPE = /^(?:Arrow)?FunctionExpression$/u;
const TARGET_METHODS =
/^(?:every|filter|find(?:Last)?(?:Index)?|flatMap|forEach|map|reduce(?:Right)?|some|sort|toSorted)$/u;
/**
* Checks a given node is a member access which has the specified name's
* property.
* @param {ASTNode} node A node to check.
* @returns {boolean} `true` if the node is a member access which has
* the specified name's property. The node may be a `(Chain|Member)Expression` node.
*/
function isTargetMethod(node) {
return astUtils.isSpecificMemberAccess(node, null, TARGET_METHODS);
}
/**
* Checks all segments in a set and returns true if any are reachable.
* @param {Set<CodePathSegment>} segments The segments to check.
* @returns {boolean} True if any segment is reachable; false otherwise.
*/
function isAnySegmentReachable(segments) {
for (const segment of segments) {
if (segment.reachable) {
return true;
}
}
return false;
}
/**
* Returns a human-legible description of an array method
* @param {string} arrayMethodName A method name to fully qualify
* @returns {string} the method name prefixed with `Array.` if it is a class method,
* or else `Array.prototype.` if it is an instance method.
*/
function fullMethodName(arrayMethodName) {
if (["from", "of", "isArray"].includes(arrayMethodName)) {
return "Array.".concat(arrayMethodName);
}
return "Array.prototype.".concat(arrayMethodName);
}
/**
* Checks whether or not a given node is a function expression which is the
* callback of an array method, returning the method name.
* @param {ASTNode} node A node to check. This is one of
* FunctionExpression or ArrowFunctionExpression.
* @returns {string} The method name if the node is a callback method,
* null otherwise.
*/
function getArrayMethodName(node) {
let currentNode = node;
while (currentNode) {
const parent = currentNode.parent;
switch (parent.type) {
/*
* Looks up the destination. e.g.,
* foo.every(nativeFoo || function foo() { ... });
*/
case "LogicalExpression":
case "ConditionalExpression":
case "ChainExpression":
currentNode = parent;
break;
/*
* If the upper function is IIFE, checks the destination of the return value.
* e.g.
* foo.every((function() {
* // setup...
* return function callback() { ... };
* })());
*/
case "ReturnStatement": {
const func = astUtils.getUpperFunction(parent);
if (func === null || !astUtils.isCallee(func)) {
return null;
}
currentNode = func.parent;
break;
}
/*
* e.g.
* Array.from([], function() {});
* list.every(function() {});
*/
case "CallExpression":
if (astUtils.isArrayFromMethod(parent.callee)) {
if (
parent.arguments.length >= 2 &&
parent.arguments[1] === currentNode
) {
return "from";
}
}
if (isTargetMethod(parent.callee)) {
if (
parent.arguments.length >= 1 &&
parent.arguments[0] === currentNode
) {
return astUtils.getStaticPropertyName(parent.callee);
}
}
return null;
// Otherwise this node is not target.
default:
return null;
}
}
/* c8 ignore next */
return null;
}
/**
* Checks if the given node is a void expression.
* @param {ASTNode} node The node to check.
* @returns {boolean} - `true` if the node is a void expression
*/
function isExpressionVoid(node) {
return node.type === "UnaryExpression" && node.operator === "void";
}
/**
* Fixes the linting error by prepending "void " to the given node
* @param {Object} sourceCode context given by context.sourceCode
* @param {ASTNode} node The node to fix.
* @param {Object} fixer The fixer object provided by ESLint.
* @returns {Array<Object>} - An array of fix objects to apply to the node.
*/
function voidPrependFixer(sourceCode, node, fixer) {
const requiresParens =
// prepending `void ` will fail if the node has a lower precedence than void
astUtils.getPrecedence(node) <
astUtils.getPrecedence({
type: "UnaryExpression",
operator: "void",
}) &&
// check if there are parentheses around the node to avoid redundant parentheses
!astUtils.isParenthesised(sourceCode, node);
// avoid parentheses issues
const returnOrArrowToken = sourceCode.getTokenBefore(
node,
node.parent.type === "ArrowFunctionExpression"
? astUtils.isArrowToken
: // isReturnToken
token => token.type === "Keyword" && token.value === "return",
);
const firstToken = sourceCode.getTokenAfter(returnOrArrowToken);
const prependSpace =
// is return token, as => allows void to be adjacent
returnOrArrowToken.value === "return" &&
// If two tokens (return and "(") are adjacent
returnOrArrowToken.range[1] === firstToken.range[0];
return [
fixer.insertTextBefore(
firstToken,
`${prependSpace ? " " : ""}void ${requiresParens ? "(" : ""}`,
),
fixer.insertTextAfter(node, requiresParens ? ")" : ""),
];
}
/**
* Fixes the linting error by `wrapping {}` around the given node's body.
* @param {Object} sourceCode context given by context.sourceCode
* @param {ASTNode} node The node to fix.
* @param {Object} fixer The fixer object provided by ESLint.
* @returns {Array<Object>} - An array of fix objects to apply to the node.
*/
function curlyWrapFixer(sourceCode, node, fixer) {
const arrowToken = sourceCode.getTokenBefore(
node.body,
astUtils.isArrowToken,
);
const firstToken = sourceCode.getTokenAfter(arrowToken);
const lastToken = sourceCode.getLastToken(node);
return [
fixer.insertTextBefore(firstToken, "{"),
fixer.insertTextAfter(lastToken, "}"),
];
}
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
/** @type {import('../shared/types').Rule} */
module.exports = {
meta: {
type: "problem",
defaultOptions: [
{
allowImplicit: false,
checkForEach: false,
allowVoid: false,
},
],
docs: {
description:
"Enforce `return` statements in callbacks of array methods",
recommended: false,
url: "https://eslint.org/docs/latest/rules/array-callback-return",
},
// eslint-disable-next-line eslint-plugin/require-meta-has-suggestions -- false positive
hasSuggestions: true,
schema: [
{
type: "object",
properties: {
allowImplicit: {
type: "boolean",
},
checkForEach: {
type: "boolean",
},
allowVoid: {
type: "boolean",
},
},
additionalProperties: false,
},
],
messages: {
expectedAtEnd:
"{{arrayMethodName}}() expects a value to be returned at the end of {{name}}.",
expectedInside:
"{{arrayMethodName}}() expects a return value from {{name}}.",
expectedReturnValue:
"{{arrayMethodName}}() expects a return value from {{name}}.",
expectedNoReturnValue:
"{{arrayMethodName}}() expects no useless return value from {{name}}.",
wrapBraces: "Wrap the expression in `{}`.",
prependVoid: "Prepend `void` to the expression.",
},
},
create(context) {
const [options] = context.options;
const sourceCode = context.sourceCode;
let funcInfo = {
arrayMethodName: null,
upper: null,
codePath: null,
hasReturn: false,
shouldCheck: false,
node: null,
};
/**
* Checks whether or not the last code path segment is reachable.
* Then reports this function if the segment is reachable.
*
* If the last code path segment is reachable, there are paths which are not
* returned or thrown.
* @param {ASTNode} node A node to check.
* @returns {void}
*/
function checkLastSegment(node) {
if (!funcInfo.shouldCheck) {
return;
}
const messageAndSuggestions = { messageId: "", suggest: [] };
if (funcInfo.arrayMethodName === "forEach") {
if (
options.checkForEach &&
node.type === "ArrowFunctionExpression" &&
node.expression
) {
if (options.allowVoid) {
if (isExpressionVoid(node.body)) {
return;
}
messageAndSuggestions.messageId =
"expectedNoReturnValue";
messageAndSuggestions.suggest = [
{
messageId: "wrapBraces",
fix(fixer) {
return curlyWrapFixer(
sourceCode,
node,
fixer,
);
},
},
{
messageId: "prependVoid",
fix(fixer) {
return voidPrependFixer(
sourceCode,
node.body,
fixer,
);
},
},
];
} else {
messageAndSuggestions.messageId =
"expectedNoReturnValue";
messageAndSuggestions.suggest = [
{
messageId: "wrapBraces",
fix(fixer) {
return curlyWrapFixer(
sourceCode,
node,
fixer,
);
},
},
];
}
}
} else {
if (
node.body.type === "BlockStatement" &&
isAnySegmentReachable(funcInfo.currentSegments)
) {
messageAndSuggestions.messageId = funcInfo.hasReturn
? "expectedAtEnd"
: "expectedInside";
}
}
if (messageAndSuggestions.messageId) {
const name = astUtils.getFunctionNameWithKind(node);
context.report({
node,
loc: astUtils.getFunctionHeadLoc(node, sourceCode),
messageId: messageAndSuggestions.messageId,
data: {
name,
arrayMethodName: fullMethodName(
funcInfo.arrayMethodName,
),
},
suggest:
messageAndSuggestions.suggest.length !== 0
? messageAndSuggestions.suggest
: null,
});
}
}
return {
// Stacks this function's information.
onCodePathStart(codePath, node) {
let methodName = null;
if (TARGET_NODE_TYPE.test(node.type)) {
methodName = getArrayMethodName(node);
}
funcInfo = {
arrayMethodName: methodName,
upper: funcInfo,
codePath,
hasReturn: false,
shouldCheck: methodName && !node.async && !node.generator,
node,
currentSegments: new Set(),
};
},
// Pops this function's information.
onCodePathEnd() {
funcInfo = funcInfo.upper;
},
onUnreachableCodePathSegmentStart(segment) {
funcInfo.currentSegments.add(segment);
},
onUnreachableCodePathSegmentEnd(segment) {
funcInfo.currentSegments.delete(segment);
},
onCodePathSegmentStart(segment) {
funcInfo.currentSegments.add(segment);
},
onCodePathSegmentEnd(segment) {
funcInfo.currentSegments.delete(segment);
},
// Checks the return statement is valid.
ReturnStatement(node) {
if (!funcInfo.shouldCheck) {
return;
}
funcInfo.hasReturn = true;
const messageAndSuggestions = { messageId: "", suggest: [] };
if (funcInfo.arrayMethodName === "forEach") {
// if checkForEach: true, returning a value at any path inside a forEach is not allowed
if (options.checkForEach && node.argument) {
if (options.allowVoid) {
if (isExpressionVoid(node.argument)) {
return;
}
messageAndSuggestions.messageId =
"expectedNoReturnValue";
messageAndSuggestions.suggest = [
{
messageId: "prependVoid",
fix(fixer) {
return voidPrependFixer(
sourceCode,
node.argument,
fixer,
);
},
},
];
} else {
messageAndSuggestions.messageId =
"expectedNoReturnValue";
}
}
} else {
// if allowImplicit: false, should also check node.argument
if (!options.allowImplicit && !node.argument) {
messageAndSuggestions.messageId = "expectedReturnValue";
}
}
if (messageAndSuggestions.messageId) {
context.report({
node,
messageId: messageAndSuggestions.messageId,
data: {
name: astUtils.getFunctionNameWithKind(
funcInfo.node,
),
arrayMethodName: fullMethodName(
funcInfo.arrayMethodName,
),
},
suggest:
messageAndSuggestions.suggest.length !== 0
? messageAndSuggestions.suggest
: null,
});
}
},
// Reports a given function if the last path is reachable.
"FunctionExpression:exit": checkLastSegment,
"ArrowFunctionExpression:exit": checkLastSegment,
};
},
};