1 |
|
2 |
|
3 | const 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 */
|
19 | function 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 | */
|
34 | function 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 */
|
45 | function 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 | */
|
55 | function 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 | */
|
67 | function 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 | */
|
123 | function peek(arr) {
|
124 | return arr[arr.length - 1]
|
125 | }
|
126 |
|
127 | module.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 | }
|