/** * @fileoverview Rule to prefer arrow functions over plain functions * @author Triston Jones */ 'use strict'; module.exports = { meta: { docs: { description: 'prefer arrow functions', category: 'emcascript6', recommended: false }, fixable: 'code', schema: [{ type: 'object', properties: { disallowPrototype: { type: 'boolean' }, singleReturnOnly: { type: 'boolean' }, classPropertiesAllowed: { type: 'boolean' }, allowStandaloneDeclarations: { type: 'boolean' } }, additionalProperties: false }] }, create: context => ({ 'FunctionDeclaration:exit': (node) => inspectNode(node, context), 'FunctionExpression:exit': (node) => inspectNode(node, context) }) } const isPrototypeAssignment = (node) => { let parent = node.parent; while(parent) { switch(parent.type) { case 'MemberExpression': if(parent.property && parent.property.name === 'prototype') return true; parent = parent.object; break; case 'AssignmentExpression': parent = parent.left; break; case 'Property': case 'ObjectExpression': parent = parent.parent; break; default: return false; } } return false; } const isConstructor = (node) => { let parent = node.parent; return parent && parent.kind === 'constructor'; } const containsThis = (node) => { if (typeof node !== 'object' || node === null) return false; if (node.type === 'ThisExpression') return true; return Object.keys(node).some(field => { if (field === 'parent') { return false; } else if (Array.isArray(node[field])) { return node[field].some(containsThis); } return containsThis(node[field]); }); } const isNamed = (node) => node.type === 'FunctionDeclaration' && node.id && node.id.name; const functionOnlyContainsReturnStatement = node => node.body.body.length === 1 && node.body.body[0].type === 'ReturnStatement'; const isNamedDefaultExport = node => node.id && node.id.name && node.parent.type === 'ExportDefaultDeclaration'; const isClassMethod = node => node.parent.type === 'MethodDefinition'; const isGeneratorFunction = node => node.generator === true; const isGetterOrSetter = node => node.parent.kind === 'set' || node.parent.kind === 'get'; const isCommonJSModuleProp = (node, name = 'module') => node && node.type === 'MemberExpression' && node.object && node.object.type === 'Identifier' && node.object.name === name; const isModuleExport = node => node.parent.type === 'AssignmentExpression' && ( isCommonJSModuleProp(node.parent.left) || isCommonJSModuleProp(node.parent.left, 'exports') || isCommonJSModuleProp(node.parent.left.object) ); const isStandaloneDeclaration = node => node.type === 'FunctionDeclaration' && ( !node.parent || node.parent.type === 'Program' || node.parent.type === 'ExportNamedDeclaration' || node.parent.type === 'ExportDefaultDeclaration' ); const inspectNode = (node, context) => { const opts = context.options[0] || {}; if(isConstructor(node)) return; if(containsThis(node) && !isClassMethod(node)) return; if(isGeneratorFunction(node)) return; if(isGetterOrSetter(node)) return; if(isClassMethod(node) && !opts.classPropertiesAllowed) return; if(opts.allowStandaloneDeclarations && (isStandaloneDeclaration(node) || isModuleExport(node))) return; if (opts.singleReturnOnly) { if (functionOnlyContainsReturnStatement(node) && !isNamedDefaultExport(node) && (opts.classPropertiesAllowed || !isClassMethod(node))) return context.report({ node, message: 'Prefer using arrow functions over plain functions which only return a value', fix(fixer) { const src = context.getSourceCode(); let newText = null; if (node.type === 'FunctionDeclaration') { newText = fixFunctionDeclaration(src, node); } else if (node.type === 'FunctionExpression') { newText = fixFunctionExpression(src, node); // In the case of an async method definition, we remove the "async" prefix if (node.async && node.parent.type === 'MethodDefinition') { const parentTokens = src.getTokens(node.parent); const asyncToken = parentTokens.find(tokenMatcher('Identifier', 'async')); const nextToken = parentTokens.find((_, i, arr) => arr[i-1] && arr[i-1] === asyncToken); return [ fixer.replaceText(node, newText), fixer.replaceTextRange([tokenStart(asyncToken), tokenStart(nextToken)], ''), ] } } if (newText !== null) { return fixer.replaceText(node, newText) } } }); } else if(opts.disallowPrototype || !isPrototypeAssignment(node)) { return context.report(node, isNamed(node) ? 'Use const or class constructors instead of named functions' : 'Prefer using arrow functions over plain functions'); } } const tokenStart = (token) => token.start === undefined ? token.range[0] : token.start; const tokenEnd = (token) => token.end === undefined ? token.range[1] : token.end; const replaceTokens = (origSource, tokens, replacements) => { let removeNextLeadingSpace = false; let result = ''; let lastTokenEnd = -1; for (const token of tokens) { if (lastTokenEnd >= 0) { let between = origSource.substring(lastTokenEnd, tokenStart(token)); if (removeNextLeadingSpace) { between = between.replace(/^\s+/, ''); } result += between; } removeNextLeadingSpace = false; if (tokenStart(token) in replacements) { const replaceInfo = replacements[tokenStart(token)]; if (replaceInfo[2]) { result = result.replace(/\s+$/, ''); } result += replaceInfo[0]; removeNextLeadingSpace = !!replaceInfo[1]; } else { result += origSource.substring(tokenStart(token), tokenEnd(token)); } lastTokenEnd = tokenEnd(token); } return result; }; const tokenMatcher = (type, value = undefined) => token => token.type === type && (typeof value === 'undefined' || token.value === value); const fixFunctionExpression = (src, node) => { const orig = src.getText(); const tokens = src.getTokens(node); const bodyTokens = src.getTokens(node.body); let swap = {}; const fnKeyword = tokens.find(tokenMatcher('Keyword', 'function')); let prefix = ''; let suffix = ''; if (fnKeyword) { swap[tokenStart(fnKeyword)] = ['', true]; const nameToken = src.getTokenAfter(fnKeyword); if (nameToken.type === 'Identifier') { swap[tokenStart(nameToken)] = ['']; } } else if (node.parent.type === 'MethodDefinition') { // The eslint Node starts with the parens, like // render() { return "hi"; } // ^--- node starts here // We need to add equals sign after the method name to convert to instance property assignment prefix = ' = '; suffix = ';' if (node.async) { prefix = ' = async '; } } else if (node.parent.type === 'Property') { // Similar to above prefix = ': '; } swap[tokenStart(bodyTokens.find(tokenMatcher('Punctuator', '{')))] = ['=> ', true]; const parens = node.body.body[0].argument.type === 'ObjectExpression'; swap[tokenStart(bodyTokens.find(tokenMatcher('Keyword', 'return')))] = [parens ? '(' : '', true]; const returnRange = node.body.body.find(n => n.type === 'ReturnStatement').range; const semicolon = bodyTokens.find(t => tokenEnd(t) == returnRange[1] && t.value === ';' && t.type === 'Punctuator'); if (semicolon) { swap[tokenStart(semicolon)] = [parens ? ')' : '', true]; } const closeBraces = bodyTokens.filter(tokenMatcher('Punctuator', '}')); const lastCloseBrace = closeBraces[closeBraces.length - 1]; swap[tokenStart(lastCloseBrace)] = ['', false, true]; return prefix + replaceTokens(orig, tokens, swap).replace(/ $/, '') + (parens && !semicolon ? ')' : '') + suffix; } const fixFunctionDeclaration = (src, node) => { const orig = src.getText(); const tokens = src.getTokens(node); const bodyTokens = src.getTokens(node.body); let swap = {}; const asyncKeyword = node.async ? 'async ' : ''; const omitVar = node.parent && node.parent.type === 'ExportDefaultDeclaration'; const parens = node.body.body[0].argument.type === 'ObjectExpression'; swap[tokenStart(tokens.find(tokenMatcher('Keyword', 'function')))] = omitVar ? ['', true] : ['const']; swap[tokenStart(tokens.find(tokenMatcher('Punctuator', '(')))] = [omitVar ? `${asyncKeyword}(` : ` = ${asyncKeyword}(`]; if (node.async) { swap[tokenStart(tokens.find(tokenMatcher('Identifier', 'async')))] = ['', true]; } if (omitVar) { const functionKeywordToken = tokens.find(tokenMatcher('Keyword', 'function')); const nameToken = src.getTokenAfter(functionKeywordToken); if (nameToken.type === 'Identifier') { swap[tokenStart(nameToken)] = ['']; } } swap[tokenStart(bodyTokens.find(tokenMatcher('Punctuator', '{')))] = ['=> ', true]; swap[tokenStart(bodyTokens.find(tokenMatcher('Keyword', 'return')))] = [parens ? '(' : '', true]; const returnRange = node.body.body.find(n => n.type === 'ReturnStatement').range; const semicolon = bodyTokens.find(t => tokenEnd(t) == returnRange[1] && t.value === ';' && t.type === 'Punctuator'); if (semicolon) { swap[tokenStart(semicolon)] = [parens ? ')' : '', true]; } const closeBraces = bodyTokens.filter(tokenMatcher('Punctuator', '}')); const lastCloseBrace = closeBraces[closeBraces.length-1]; swap[tokenStart(lastCloseBrace)] = ['', false, true]; return replaceTokens(orig, tokens, swap).replace(/ $/, '') + (parens && !semicolon ? ');' : ';'); }