315 lines
8.6 KiB
Plaintext
315 lines
8.6 KiB
Plaintext
'use strict'
|
|
|
|
const getDocsUrl = require('./lib/get-docs-url')
|
|
|
|
/**
|
|
* @typedef {import('estree').Node} Node
|
|
* @typedef {import('estree').SimpleCallExpression} CallExpression
|
|
* @typedef {import('estree').FunctionExpression} FunctionExpression
|
|
* @typedef {import('estree').ArrowFunctionExpression} ArrowFunctionExpression
|
|
* @typedef {import('eslint').Rule.CodePath} CodePath
|
|
* @typedef {import('eslint').Rule.CodePathSegment} CodePathSegment
|
|
*/
|
|
|
|
/**
|
|
* @typedef { (FunctionExpression | ArrowFunctionExpression) & { parent: CallExpression }} InlineThenFunctionExpression
|
|
*/
|
|
|
|
/** @param {Node} node */
|
|
function isFunctionWithBlockStatement(node) {
|
|
if (node.type === 'FunctionExpression') {
|
|
return true
|
|
}
|
|
if (node.type === 'ArrowFunctionExpression') {
|
|
return node.body.type === 'BlockStatement'
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @param {string} memberName
|
|
* @param {Node} node
|
|
* @returns {node is CallExpression}
|
|
*/
|
|
function isMemberCall(memberName, node) {
|
|
return (
|
|
node.type === 'CallExpression' &&
|
|
node.callee.type === 'MemberExpression' &&
|
|
!node.callee.computed &&
|
|
node.callee.property.type === 'Identifier' &&
|
|
node.callee.property.name === memberName
|
|
)
|
|
}
|
|
|
|
/** @param {Node} node */
|
|
function isFirstArgument(node) {
|
|
return Boolean(
|
|
node.parent && node.parent.arguments && node.parent.arguments[0] === node,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* @param {Node} node
|
|
* @returns {node is InlineThenFunctionExpression}
|
|
*/
|
|
function isInlineThenFunctionExpression(node) {
|
|
return (
|
|
isFunctionWithBlockStatement(node) &&
|
|
isMemberCall('then', node.parent) &&
|
|
isFirstArgument(node)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Checks whether the given node is the last `then()` callback in a promise chain.
|
|
* @param {InlineThenFunctionExpression} node
|
|
*/
|
|
function isLastCallback(node) {
|
|
/** @type {Node} */
|
|
let target = node.parent
|
|
/** @type {Node | undefined} */
|
|
let parent = target.parent
|
|
while (parent) {
|
|
if (parent.type === 'ExpressionStatement') {
|
|
// e.g. { promise.then(() => value) }
|
|
return true
|
|
}
|
|
if (parent.type === 'UnaryExpression') {
|
|
// e.g. void promise.then(() => value)
|
|
return parent.operator === 'void'
|
|
}
|
|
/** @type {Node | null} */
|
|
let nextTarget = null
|
|
if (parent.type === 'SequenceExpression') {
|
|
if (peek(parent.expressions) !== target) {
|
|
// e.g. (promise?.then(() => value), expr)
|
|
return true
|
|
}
|
|
nextTarget = parent
|
|
} else if (
|
|
// e.g. promise?.then(() => value)
|
|
parent.type === 'ChainExpression' ||
|
|
// e.g. await promise.then(() => value)
|
|
parent.type === 'AwaitExpression'
|
|
) {
|
|
nextTarget = parent
|
|
} else if (parent.type === 'MemberExpression') {
|
|
if (
|
|
parent.parent &&
|
|
(isMemberCall('catch', parent.parent) ||
|
|
isMemberCall('finally', parent.parent))
|
|
) {
|
|
// e.g. promise.then(() => value).catch(e => {})
|
|
nextTarget = parent.parent
|
|
}
|
|
}
|
|
if (nextTarget) {
|
|
target = nextTarget
|
|
parent = target.parent
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
|
|
// istanbul ignore next
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @template T
|
|
* @param {T[]} arr
|
|
* @returns {T}
|
|
*/
|
|
function peek(arr) {
|
|
return arr[arr.length - 1]
|
|
}
|
|
|
|
/**
|
|
* Gets the root object name for a MemberExpression or Identifier.
|
|
* @param {Node} node
|
|
* @returns {string | undefined}
|
|
*/
|
|
function getRootObjectName(node) {
|
|
if (node.type === 'Identifier') {
|
|
return node.name
|
|
}
|
|
// istanbul ignore else (fallback)
|
|
if (node.type === 'MemberExpression') {
|
|
return getRootObjectName(node.object)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the node is an assignment to an ignored variable.
|
|
* @param {Node} node
|
|
* @param {string[]} ignoredVars
|
|
* @returns {boolean}
|
|
*/
|
|
function isIgnoredAssignment(node, ignoredVars) {
|
|
if (node.type !== 'ExpressionStatement') return false
|
|
const expr = node.expression
|
|
if (expr.type !== 'AssignmentExpression') return false
|
|
const left = expr.left
|
|
const rootName = getRootObjectName(left)
|
|
return ignoredVars.includes(rootName)
|
|
}
|
|
|
|
module.exports = {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description:
|
|
'Require returning inside each `then()` to create readable and reusable Promise chains.',
|
|
url: getDocsUrl('always-return'),
|
|
},
|
|
schema: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
ignoreLastCallback: {
|
|
type: 'boolean',
|
|
},
|
|
ignoreAssignmentVariable: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'string',
|
|
pattern: '^[\\w$]+$',
|
|
},
|
|
uniqueItems: true,
|
|
},
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
],
|
|
messages: {
|
|
thenShouldReturnOrThrow: 'Each then() should return a value or throw',
|
|
},
|
|
},
|
|
create(context) {
|
|
const options = context.options[0] || {}
|
|
const ignoreLastCallback = !!options.ignoreLastCallback
|
|
const ignoreAssignmentVariable = options.ignoreAssignmentVariable || [
|
|
'globalThis',
|
|
]
|
|
|
|
/**
|
|
* @typedef {object} FuncInfo
|
|
* @property {string[]} branchIDStack This is a stack representing the currently
|
|
* executing branches ("codePathSegment"s) within the given function
|
|
* @property {Record<string, BranchInfo | undefined>} branchInfoMap This is an object representing information
|
|
* about all branches within the given function
|
|
*
|
|
* @typedef {object} BranchInfo
|
|
* @property {boolean} good This is a boolean representing whether
|
|
* the given branch explicitly `return`s or `throw`s. It starts as `false`
|
|
* for every branch and is updated to `true` if a `return` or `throw`
|
|
* statement is found
|
|
* @property {Node} node This is a estree Node object
|
|
* for the given branch
|
|
*/
|
|
|
|
/**
|
|
* funcInfoStack is a stack representing the stack of currently executing
|
|
* functions
|
|
* example:
|
|
* funcInfoStack = [ { branchIDStack: [ 's1_1' ],
|
|
* branchInfoMap:
|
|
* { s1_1:
|
|
* { good: false,
|
|
* loc: <loc> } } },
|
|
* { branchIDStack: ['s2_1', 's2_4'],
|
|
* branchInfoMap:
|
|
* { s2_1:
|
|
* { good: false,
|
|
* loc: <loc> },
|
|
* s2_2:
|
|
* { good: true,
|
|
* loc: <loc> },
|
|
* s2_4:
|
|
* { good: false,
|
|
* loc: <loc> } } } ]
|
|
* @type {FuncInfo[]}
|
|
*/
|
|
const funcInfoStack = []
|
|
|
|
function markCurrentBranchAsGood() {
|
|
const funcInfo = peek(funcInfoStack)
|
|
const currentBranchID = peek(funcInfo.branchIDStack)
|
|
if (funcInfo.branchInfoMap[currentBranchID]) {
|
|
funcInfo.branchInfoMap[currentBranchID].good = true
|
|
}
|
|
// else unreachable code
|
|
}
|
|
|
|
return {
|
|
'ReturnStatement:exit': markCurrentBranchAsGood,
|
|
'ThrowStatement:exit': markCurrentBranchAsGood,
|
|
'ExpressionStatement > CallExpression > MemberExpression[object.name="process"][property.name="exit"]:exit':
|
|
markCurrentBranchAsGood,
|
|
'ExpressionStatement > CallExpression > MemberExpression[object.name="process"][property.name="abort"]:exit':
|
|
markCurrentBranchAsGood,
|
|
|
|
/**
|
|
* @param {CodePathSegment} segment
|
|
* @param {Node} node
|
|
*/
|
|
onCodePathSegmentStart(segment, node) {
|
|
const funcInfo = peek(funcInfoStack)
|
|
funcInfo.branchIDStack.push(segment.id)
|
|
funcInfo.branchInfoMap[segment.id] = { good: false, node }
|
|
},
|
|
|
|
onCodePathSegmentEnd() {
|
|
const funcInfo = peek(funcInfoStack)
|
|
funcInfo.branchIDStack.pop()
|
|
},
|
|
|
|
onCodePathStart() {
|
|
funcInfoStack.push({
|
|
branchIDStack: [],
|
|
branchInfoMap: {},
|
|
})
|
|
},
|
|
|
|
/**
|
|
* @param {CodePath} path
|
|
* @param {Node} node
|
|
*/
|
|
onCodePathEnd(path, node) {
|
|
const funcInfo = funcInfoStack.pop()
|
|
|
|
if (!isInlineThenFunctionExpression(node)) {
|
|
return
|
|
}
|
|
|
|
if (ignoreLastCallback && isLastCallback(node)) {
|
|
return
|
|
}
|
|
|
|
if (
|
|
ignoreAssignmentVariable.length &&
|
|
isLastCallback(node) &&
|
|
node.body?.type === 'BlockStatement'
|
|
) {
|
|
for (const statement of node.body.body) {
|
|
if (isIgnoredAssignment(statement, ignoreAssignmentVariable)) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
path.finalSegments.forEach((segment) => {
|
|
const id = segment.id
|
|
const branch = funcInfo.branchInfoMap[id]
|
|
if (!branch.good) {
|
|
context.report({
|
|
messageId: 'thenShouldReturnOrThrow',
|
|
node: branch.node,
|
|
})
|
|
}
|
|
})
|
|
},
|
|
}
|
|
},
|
|
}
|