UNPKG

eslint-plugin-promise

Version:
315 lines (289 loc) 8.77 kB
'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, }) } }) }, } }, }