UNPKG

6.81 kBJavaScriptView Raw
1'use strict'
2
3const getDocsUrl = require('./lib/get-docs-url')
4
5/**
6 * @typedef {import('estree').Node} Node
7 * @typedef {import('estree').SimpleCallExpression} CallExpression
8 * @typedef {import('estree').FunctionExpression} FunctionExpression
9 * @typedef {import('estree').ArrowFunctionExpression} ArrowFunctionExpression
10 * @typedef {import('eslint').Rule.CodePath} CodePath
11 * @typedef {import('eslint').Rule.CodePathSegment} CodePathSegment
12 */
13
14/**
15 * @typedef { (FunctionExpression | ArrowFunctionExpression) & { parent: CallExpression }} InlineThenFunctionExpression
16 */
17
18/** @param {Node} node */
19function isFunctionWithBlockStatement(node) {
20 if (node.type === 'FunctionExpression') {
21 return true
22 }
23 if (node.type === 'ArrowFunctionExpression') {
24 return node.body.type === 'BlockStatement'
25 }
26 return false
27}
28
29/**
30 * @param {string} memberName
31 * @param {Node} node
32 * @returns {node is CallExpression}
33 */
34function isMemberCall(memberName, node) {
35 return (
36 node.type === 'CallExpression' &&
37 node.callee.type === 'MemberExpression' &&
38 !node.callee.computed &&
39 node.callee.property.type === 'Identifier' &&
40 node.callee.property.name === memberName
41 )
42}
43
44/** @param {Node} node */
45function isFirstArgument(node) {
46 return Boolean(
47 node.parent && node.parent.arguments && node.parent.arguments[0] === node
48 )
49}
50
51/**
52 * @param {Node} node
53 * @returns {node is InlineThenFunctionExpression}
54 */
55function isInlineThenFunctionExpression(node) {
56 return (
57 isFunctionWithBlockStatement(node) &&
58 isMemberCall('then', node.parent) &&
59 isFirstArgument(node)
60 )
61}
62
63/**
64 * Checks whether the given node is the last `then()` callback in a promise chain.
65 * @param {InlineThenFunctionExpression} node
66 */
67function isLastCallback(node) {
68 /** @type {Node} */
69 let target = node.parent
70 /** @type {Node | undefined} */
71 let parent = target.parent
72 while (parent) {
73 if (parent.type === 'ExpressionStatement') {
74 // e.g. { promise.then(() => value) }
75 return true
76 }
77 if (parent.type === 'UnaryExpression') {
78 // e.g. void promise.then(() => value)
79 return parent.operator === 'void'
80 }
81 /** @type {Node | null} */
82 let nextTarget = null
83 if (parent.type === 'SequenceExpression') {
84 if (peek(parent.expressions) !== target) {
85 // e.g. (promise?.then(() => value), expr)
86 return true
87 }
88 nextTarget = parent
89 } else if (
90 // e.g. promise?.then(() => value)
91 parent.type === 'ChainExpression' ||
92 // e.g. await promise.then(() => value)
93 parent.type === 'AwaitExpression'
94 ) {
95 nextTarget = parent
96 } else if (parent.type === 'MemberExpression') {
97 if (
98 parent.parent &&
99 (isMemberCall('catch', parent.parent) ||
100 isMemberCall('finally', parent.parent))
101 ) {
102 // e.g. promise.then(() => value).catch(e => {})
103 nextTarget = parent.parent
104 }
105 }
106 if (nextTarget) {
107 target = nextTarget
108 parent = target.parent
109 continue
110 }
111 return false
112 }
113
114 // istanbul ignore next
115 return false
116}
117
118/**
119 * @template T
120 * @param {T[]} arr
121 * @returns {T}
122 */
123function peek(arr) {
124 return arr[arr.length - 1]
125}
126
127module.exports = {
128 meta: {
129 type: 'problem',
130 docs: {
131 url: getDocsUrl('always-return'),
132 },
133 schema: [
134 {
135 type: 'object',
136 properties: {
137 ignoreLastCallback: {
138 type: 'boolean',
139 },
140 },
141 additionalProperties: false,
142 },
143 ],
144 },
145 create(context) {
146 const options = context.options[0] || {}
147 const ignoreLastCallback = !!options.ignoreLastCallback
148 /**
149 * @typedef {object} FuncInfo
150 * @property {string[]} branchIDStack This is a stack representing the currently
151 * executing branches ("codePathSegment"s) within the given function
152 * @property {Record<string, BranchInfo | undefined>} branchInfoMap This is an object representing information
153 * about all branches within the given function
154 *
155 * @typedef {object} BranchInfo
156 * @property {boolean} good This is a boolean representing whether
157 * the given branch explicitly `return`s or `throw`s. It starts as `false`
158 * for every branch and is updated to `true` if a `return` or `throw`
159 * statement is found
160 * @property {Node} node This is a estree Node object
161 * for the given branch
162 */
163
164 /**
165 * funcInfoStack is a stack representing the stack of currently executing
166 * functions
167 * example:
168 * funcInfoStack = [ { branchIDStack: [ 's1_1' ],
169 * branchInfoMap:
170 * { s1_1:
171 * { good: false,
172 * loc: <loc> } } },
173 * { branchIDStack: ['s2_1', 's2_4'],
174 * branchInfoMap:
175 * { s2_1:
176 * { good: false,
177 * loc: <loc> },
178 * s2_2:
179 * { good: true,
180 * loc: <loc> },
181 * s2_4:
182 * { good: false,
183 * loc: <loc> } } } ]
184 * @type {FuncInfo[]}
185 */
186 const funcInfoStack = []
187
188 function markCurrentBranchAsGood() {
189 const funcInfo = peek(funcInfoStack)
190 const currentBranchID = peek(funcInfo.branchIDStack)
191 if (funcInfo.branchInfoMap[currentBranchID]) {
192 funcInfo.branchInfoMap[currentBranchID].good = true
193 }
194 // else unreachable code
195 }
196
197 return {
198 'ReturnStatement:exit': markCurrentBranchAsGood,
199 'ThrowStatement:exit': markCurrentBranchAsGood,
200
201 /**
202 * @param {CodePathSegment} segment
203 * @param {Node} node
204 */
205 onCodePathSegmentStart(segment, node) {
206 const funcInfo = peek(funcInfoStack)
207 funcInfo.branchIDStack.push(segment.id)
208 funcInfo.branchInfoMap[segment.id] = { good: false, node }
209 },
210
211 onCodePathSegmentEnd() {
212 const funcInfo = peek(funcInfoStack)
213 funcInfo.branchIDStack.pop()
214 },
215
216 onCodePathStart() {
217 funcInfoStack.push({
218 branchIDStack: [],
219 branchInfoMap: {},
220 })
221 },
222
223 /**
224 * @param {CodePath} path
225 * @param {Node} node
226 */
227 onCodePathEnd(path, node) {
228 const funcInfo = funcInfoStack.pop()
229
230 if (!isInlineThenFunctionExpression(node)) {
231 return
232 }
233
234 if (ignoreLastCallback && isLastCallback(node)) {
235 return
236 }
237
238 path.finalSegments.forEach((segment) => {
239 const id = segment.id
240 const branch = funcInfo.branchInfoMap[id]
241 if (!branch.good) {
242 context.report({
243 message: 'Each then() should return a value or throw',
244 node: branch.node,
245 })
246 }
247 })
248 },
249 }
250 },
251}