1 | ;
|
2 |
|
3 | /*
|
4 | * relax complexity and max-statements eslint rules in order to preserve the imported
|
5 | * core eslint `prefer-arrow-callback` rule code so that future updates to that code
|
6 | * can be more easily applied here.
|
7 | */
|
8 | /* eslint "complexity": [ "error", 18 ], "max-statements": [ "error", 15 ] */
|
9 |
|
10 | /**
|
11 | * @fileoverview A rule to suggest using arrow functions as callbacks.
|
12 | * @author Toru Nagashima (core eslint rule)
|
13 | * @author Michael Fields (mocha-aware rule modifications)
|
14 | */
|
15 | const astUtils = require('../util/ast');
|
16 |
|
17 | // ------------------------------------------------------------------------------
|
18 | // Helpers
|
19 | // ------------------------------------------------------------------------------
|
20 |
|
21 | /**
|
22 | * Checks whether or not a given variable is a function name.
|
23 | * @param {eslint-scope.Variable} variable - A variable to check.
|
24 | * @returns {boolean} `true` if the variable is a function name.
|
25 | */
|
26 | function isFunctionName(variable) {
|
27 | return variable && variable.defs[0].type === 'FunctionName';
|
28 | }
|
29 |
|
30 | /**
|
31 | * Gets the variable object of `arguments` which is defined implicitly.
|
32 | * @param {eslint-scope.Scope} scope - A scope to get.
|
33 | * @returns {eslint-scope.Variable} The found variable object.
|
34 | */
|
35 | function getVariableOfArguments(scope) {
|
36 | const variables = scope.variables;
|
37 | let variableObject = null;
|
38 |
|
39 | for (let i = 0; i < variables.length; i += 1) {
|
40 | const variable = variables[i];
|
41 |
|
42 | /*
|
43 | * If there was a parameter which is named "arguments", the
|
44 | * implicit "arguments" is not defined.
|
45 | * So does fast return with null.
|
46 | */
|
47 | if (variable.name === 'arguments' && variable.identifiers.length === 0) {
|
48 | variableObject = variable;
|
49 | break;
|
50 | }
|
51 | }
|
52 | return variableObject;
|
53 | }
|
54 |
|
55 | /**
|
56 | * Does the node property indicate a `bind` identifier
|
57 | * @param {object} property - the node property.
|
58 | * @returns {boolean}
|
59 | */
|
60 | function propertyIndicatesBind(property) {
|
61 | return !property.computed &&
|
62 | property.type === 'Identifier' &&
|
63 | property.name === 'bind';
|
64 | }
|
65 |
|
66 | /**
|
67 | * Is the node a `.bind(this)` node?
|
68 | * @param {ASTNode} node - the node property.
|
69 | * @returns {boolean}
|
70 | */
|
71 | function isBindThis(node, currentNode) {
|
72 | return node.object === currentNode &&
|
73 | propertyIndicatesBind(node.property) &&
|
74 | node.parent.type === 'CallExpression' &&
|
75 | node.parent.callee === node;
|
76 | }
|
77 |
|
78 | /**
|
79 | * Checkes whether or not a given node is a callback.
|
80 | * @param {ASTNode} node - A node to check.
|
81 | * @param {object} context - The eslint context.
|
82 | * @returns {Object}
|
83 | * {boolean} retv.isCallback - `true` if the node is a callback.
|
84 | * {boolean} retv.isMochaCallback - `true` if the node is an argument to a mocha function.
|
85 | * {boolean} retv.isLexicalThis - `true` if the node is with `.bind(this)`.
|
86 | */
|
87 | function getCallbackInfo(node, context) {
|
88 | const retv = { isCallback: false, isLexicalThis: false };
|
89 | let searchComplete = false;
|
90 | let currentNode = node;
|
91 | let parent = node.parent;
|
92 |
|
93 | while (currentNode && !searchComplete) {
|
94 | switch (parent.type) {
|
95 | // Checks parents recursively.
|
96 |
|
97 | case 'LogicalExpression':
|
98 | case 'ConditionalExpression':
|
99 | break;
|
100 |
|
101 | // Checks whether the parent node is `.bind(this)` call.
|
102 | case 'MemberExpression':
|
103 | if (isBindThis(parent, currentNode)) {
|
104 | retv.isLexicalThis =
|
105 | parent.parent.arguments.length === 1 &&
|
106 | parent.parent.arguments[0].type === 'ThisExpression';
|
107 | parent = parent.parent;
|
108 | } else {
|
109 | searchComplete = true;
|
110 | }
|
111 | break;
|
112 |
|
113 | // Checks whether the node is a callback.
|
114 | case 'CallExpression':
|
115 | case 'NewExpression':
|
116 | if (parent.callee !== currentNode) {
|
117 | retv.isCallback = true;
|
118 | }
|
119 | // Checks whether the node is a mocha function callback.
|
120 | if (retv.isCallback && astUtils.isMochaFunctionCall(parent, context.getScope())) {
|
121 | retv.isMochaCallback = true;
|
122 | }
|
123 | searchComplete = true;
|
124 | break;
|
125 |
|
126 | default:
|
127 | searchComplete = true;
|
128 | }
|
129 |
|
130 | if (!searchComplete) {
|
131 | currentNode = parent;
|
132 | parent = parent.parent;
|
133 | }
|
134 | }
|
135 | return retv;
|
136 | }
|
137 |
|
138 | /**
|
139 | * Checks whether a simple list of parameters contains any duplicates. This does not handle complex
|
140 | * parameter lists (e.g. with destructuring), since complex parameter lists are a SyntaxError with duplicate
|
141 | * parameter names anyway. Instead, it always returns `false` for complex parameter lists.
|
142 | * @param {ASTNode[]} paramsList The list of parameters for a function
|
143 | * @returns {boolean} `true` if the list of parameters contains any duplicates
|
144 | */
|
145 | function hasDuplicateParams(paramsList) {
|
146 | return paramsList.every((param) => param.type === 'Identifier') &&
|
147 | paramsList.length !== new Set(paramsList.map((param) => param.name)).size;
|
148 | }
|
149 |
|
150 | // ------------------------------------------------------------------------------
|
151 | // Rule Definition
|
152 | // ------------------------------------------------------------------------------
|
153 |
|
154 | module.exports = {
|
155 | meta: {
|
156 | docs: {
|
157 | description: 'require using arrow functions for callbacks',
|
158 | category: 'ECMAScript 6',
|
159 | recommended: false,
|
160 | url: 'https://eslint.org/docs/rules/prefer-arrow-callback'
|
161 | },
|
162 |
|
163 | schema: [
|
164 | {
|
165 | type: 'object',
|
166 | properties: {
|
167 | allowNamedFunctions: {
|
168 | type: 'boolean'
|
169 | },
|
170 | allowUnboundThis: {
|
171 | type: 'boolean'
|
172 | }
|
173 | },
|
174 | additionalProperties: false
|
175 | }
|
176 | ],
|
177 |
|
178 | fixable: 'code'
|
179 | },
|
180 |
|
181 | create(context) {
|
182 | const options = context.options[0] || {};
|
183 |
|
184 | // allowUnboundThis defaults to true
|
185 | const allowUnboundThis = options.allowUnboundThis !== false;
|
186 | const allowNamedFunctions = options.allowNamedFunctions;
|
187 | const sourceCode = context.getSourceCode();
|
188 |
|
189 | /*
|
190 | * {Array<{this: boolean, meta: boolean}>}
|
191 | * - this - A flag which shows there are one or more ThisExpression.
|
192 | * - meta - A flag which shows there are one or more MetaProperty.
|
193 | */
|
194 | let stack = [];
|
195 |
|
196 | /**
|
197 | * Pushes new function scope with all `false` flags.
|
198 | * @returns {void}
|
199 | */
|
200 | function enterScope() {
|
201 | stack.push({ this: false, meta: false });
|
202 | }
|
203 |
|
204 | /**
|
205 | * Pops a function scope from the stack.
|
206 | * @returns {{this: boolean, meta: boolean}} The information of the last scope.
|
207 | */
|
208 | function exitScope() {
|
209 | return stack.pop();
|
210 | }
|
211 |
|
212 | return {
|
213 |
|
214 | // Reset internal state.
|
215 | Program() {
|
216 | stack = [];
|
217 | },
|
218 |
|
219 | // If there are below, it cannot replace with arrow functions merely.
|
220 | ThisExpression() {
|
221 | const info = stack[stack.length - 1];
|
222 |
|
223 | if (info) {
|
224 | info.this = true;
|
225 | }
|
226 | },
|
227 |
|
228 | MetaProperty() {
|
229 | const info = stack[stack.length - 1];
|
230 |
|
231 | info.meta = true;
|
232 | },
|
233 |
|
234 | // To skip nested scopes.
|
235 | FunctionDeclaration: enterScope,
|
236 | 'FunctionDeclaration:exit': exitScope,
|
237 |
|
238 | // Main.
|
239 | FunctionExpression: enterScope,
|
240 | 'FunctionExpression:exit'(node) {
|
241 | const scopeInfo = exitScope();
|
242 |
|
243 | // Skip named function expressions
|
244 | if (allowNamedFunctions && node.id && node.id.name) {
|
245 | return;
|
246 | }
|
247 |
|
248 | // Skip generators.
|
249 | if (node.generator) {
|
250 | return;
|
251 | }
|
252 |
|
253 | // Skip recursive functions.
|
254 | const nameVar = context.getDeclaredVariables(node)[0];
|
255 |
|
256 | if (isFunctionName(nameVar) && nameVar.references.length > 0) {
|
257 | return;
|
258 | }
|
259 |
|
260 | // Skip if it's using arguments.
|
261 | const variable = getVariableOfArguments(context.getScope());
|
262 |
|
263 | if (variable && variable.references.length > 0) {
|
264 | return;
|
265 | }
|
266 |
|
267 | // Reports if it's a callback which can replace with arrows.
|
268 | const callbackInfo = getCallbackInfo(node, context);
|
269 |
|
270 | if (callbackInfo.isCallback &&
|
271 | (!allowUnboundThis || !scopeInfo.this || callbackInfo.isLexicalThis) &&
|
272 | !scopeInfo.meta &&
|
273 | !callbackInfo.isMochaCallback
|
274 | ) {
|
275 | context.report({
|
276 | node,
|
277 | message: 'Unexpected function expression.',
|
278 | fix(fixer) {
|
279 | if (!callbackInfo.isLexicalThis && scopeInfo.this || hasDuplicateParams(node.params)) {
|
280 | /*
|
281 | * If the callback function does not have .bind(this) and contains a reference to
|
282 | * `this`, there is no way to determine what `this` should be, so don't perform any
|
283 | * fixes. If the callback function has duplicates in its list of parameters (possible
|
284 | * in sloppy mode), don't replace it with an arrow function, because this is a
|
285 | * SyntaxError with arrow functions.
|
286 | */
|
287 | return null;
|
288 | }
|
289 |
|
290 | const paramsLeftParen = node.params.length ?
|
291 | sourceCode.getTokenBefore(node.params[0]) :
|
292 | sourceCode.getTokenBefore(node.body, 1);
|
293 | const paramsRightParen = sourceCode.getTokenBefore(node.body);
|
294 | const asyncKeyword = node.async ? 'async ' : '';
|
295 | const paramsFullText = sourceCode.text.slice(
|
296 | paramsLeftParen.range[0], paramsRightParen.range[1]
|
297 | );
|
298 | const arrowFunctionText =
|
299 | `${asyncKeyword}${paramsFullText} => ${sourceCode.getText(node.body)}`;
|
300 |
|
301 | /*
|
302 | * If the callback function has `.bind(this)`, replace it with an arrow function and remove
|
303 | * the binding. Otherwise, just replace the arrow function itself.
|
304 | */
|
305 | const replacedNode = callbackInfo.isLexicalThis ? node.parent.parent : node;
|
306 |
|
307 | /*
|
308 | * If the replaced node is part of a BinaryExpression, LogicalExpression, or
|
309 | * MemberExpression, then the arrow function needs to be parenthesized, because
|
310 | * `foo || () => {}` is invalid syntax even though `foo || function() {}` is valid.
|
311 | */
|
312 | const needsParens = replacedNode.parent.type !== 'CallExpression' &&
|
313 | replacedNode.parent.type !== 'ConditionalExpression';
|
314 | const replacementText = needsParens ? `(${arrowFunctionText})` : arrowFunctionText;
|
315 |
|
316 | return fixer.replaceText(replacedNode, replacementText);
|
317 | }
|
318 | });
|
319 | }
|
320 | }
|
321 | };
|
322 | }
|
323 | };
|