prefer-object-from-entries.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. 'use strict';
  2. const {isCommaToken, isArrowToken, isClosingParenToken} = require('eslint-utils');
  3. const getDocumentationUrl = require('./utils/get-documentation-url.js');
  4. const {matches, methodCallSelector} = require('./selectors/index.js');
  5. const {removeParentheses} = require('./fix/index.js');
  6. const {getParentheses, getParenthesizedText} = require('./utils/parentheses.js');
  7. const {isNodeMatches, isNodeMatchesNameOrPath} = require('./utils/is-node-matches.js');
  8. const MESSAGE_ID_REDUCE = 'reduce';
  9. const MESSAGE_ID_FUNCTION = 'function';
  10. const messages = {
  11. [MESSAGE_ID_REDUCE]: 'Prefer `Object.fromEntries()` over `Array#reduce()`.',
  12. [MESSAGE_ID_FUNCTION]: 'Prefer `Object.fromEntries()` over `{{functionName}}()`.',
  13. };
  14. const createEmptyObjectSelector = path => {
  15. const prefix = path ? `${path}.` : '';
  16. return matches([
  17. // `{}`
  18. `[${prefix}type="ObjectExpression"][${prefix}properties.length=0]`,
  19. // `Object.create(null)`
  20. [
  21. methodCallSelector({path, object: 'Object', method: 'create', argumentsLength: 1}),
  22. `[${prefix}arguments.0.type="Literal"]`,
  23. `[${prefix}arguments.0.raw="null"]`,
  24. ].join(''),
  25. ]);
  26. };
  27. const createArrowCallbackSelector = path => {
  28. const prefix = path ? `${path}.` : '';
  29. return [
  30. `[${prefix}type="ArrowFunctionExpression"]`,
  31. `[${prefix}async!=true]`,
  32. `[${prefix}generator!=true]`,
  33. `[${prefix}params.length>=1]`,
  34. `[${prefix}params.0.type="Identifier"]`,
  35. ].join('');
  36. };
  37. const createPropertySelector = path => {
  38. const prefix = path ? `${path}.` : '';
  39. return [
  40. `[${prefix}type="Property"]`,
  41. `[${prefix}kind="init"]`,
  42. `[${prefix}method!=true]`,
  43. ].join('');
  44. };
  45. // - `pairs.reduce(…, {})`
  46. // - `pairs.reduce(…, Object.create(null))`
  47. const arrayReduceWithEmptyObject = [
  48. methodCallSelector({method: 'reduce', argumentsLength: 2}),
  49. createEmptyObjectSelector('arguments.1'),
  50. ].join('');
  51. const fixableArrayReduceCases = [
  52. {
  53. selector: [
  54. arrayReduceWithEmptyObject,
  55. // () => Object.assign(object, {key})
  56. createArrowCallbackSelector('arguments.0'),
  57. methodCallSelector({path: 'arguments.0.body', object: 'Object', method: 'assign', argumentsLength: 2}),
  58. '[arguments.0.body.arguments.0.type="Identifier"]',
  59. '[arguments.0.body.arguments.1.type="ObjectExpression"]',
  60. '[arguments.0.body.arguments.1.properties.length=1]',
  61. createPropertySelector('arguments.0.body.arguments.1.properties.0'),
  62. ].join(''),
  63. test: callback => callback.params[0].name === callback.body.arguments[0].name,
  64. getProperty: callback => callback.body.arguments[1].properties[0],
  65. },
  66. {
  67. selector: [
  68. arrayReduceWithEmptyObject,
  69. // () => ({...object, key})
  70. createArrowCallbackSelector('arguments.0'),
  71. '[arguments.0.body.type="ObjectExpression"]',
  72. '[arguments.0.body.properties.length=2]',
  73. '[arguments.0.body.properties.0.type="SpreadElement"]',
  74. '[arguments.0.body.properties.0.argument.type="Identifier"]',
  75. createPropertySelector('arguments.0.body.properties.1'),
  76. ].join(''),
  77. test: callback => callback.params[0].name === callback.body.properties[0].argument.name,
  78. getProperty: callback => callback.body.properties[1],
  79. },
  80. ];
  81. // `_.flatten(array)`
  82. const lodashFromPairsFunctions = [
  83. '_.fromPairs',
  84. 'lodash.fromPairs',
  85. ];
  86. const anyCall = [
  87. 'CallExpression',
  88. '[optional!=true]',
  89. '[arguments.length=1]',
  90. '[arguments.0.type!="SpreadElement"]',
  91. ' > .callee',
  92. ].join('');
  93. function fixReduceAssignOrSpread({sourceCode, node, property}) {
  94. const removeInitObject = fixer => {
  95. const initObject = node.arguments[1];
  96. const parentheses = getParentheses(initObject, sourceCode);
  97. const firstToken = parentheses[0] || initObject;
  98. const lastToken = parentheses[parentheses.length - 1] || initObject;
  99. const startToken = sourceCode.getTokenBefore(firstToken);
  100. const [start] = startToken.range;
  101. const [, end] = lastToken.range;
  102. return fixer.replaceTextRange([start, end], '');
  103. };
  104. function * removeFirstParameter(fixer) {
  105. const parameters = node.arguments[0].params;
  106. const [firstParameter] = parameters;
  107. const tokenAfter = sourceCode.getTokenAfter(firstParameter);
  108. if (isCommaToken(tokenAfter)) {
  109. yield fixer.remove(tokenAfter);
  110. }
  111. let shouldAddParentheses = false;
  112. if (parameters.length === 1) {
  113. const arrowToken = sourceCode.getTokenAfter(firstParameter, isArrowToken);
  114. const tokenBeforeArrowToken = sourceCode.getTokenBefore(arrowToken);
  115. if (!isClosingParenToken(tokenBeforeArrowToken)) {
  116. shouldAddParentheses = true;
  117. }
  118. }
  119. yield fixer.replaceText(firstParameter, shouldAddParentheses ? '()' : '');
  120. }
  121. const getKeyValueText = () => {
  122. const {key, value} = property;
  123. let keyText = getParenthesizedText(key, sourceCode);
  124. const valueText = getParenthesizedText(value, sourceCode);
  125. if (!property.computed && key.type === 'Identifier') {
  126. keyText = `'${keyText}'`;
  127. }
  128. return {keyText, valueText};
  129. };
  130. function * replaceFunctionBody(fixer) {
  131. const functionBody = node.arguments[0].body;
  132. const {keyText, valueText} = getKeyValueText();
  133. yield fixer.replaceText(functionBody, `[${keyText}, ${valueText}]`);
  134. yield * removeParentheses(functionBody, fixer, sourceCode);
  135. }
  136. return function * (fixer) {
  137. // Wrap `array.reduce()` with `Object.fromEntries()`
  138. yield fixer.insertTextBefore(node, 'Object.fromEntries(');
  139. yield fixer.insertTextAfter(node, ')');
  140. // Switch `.reduce` to `.map`
  141. yield fixer.replaceText(node.callee.property, 'map');
  142. // Remove empty object
  143. yield removeInitObject(fixer);
  144. // Remove the first parameter
  145. yield * removeFirstParameter(fixer);
  146. // Replace function body
  147. yield * replaceFunctionBody(fixer);
  148. };
  149. }
  150. /** @param {import('eslint').Rule.RuleContext} context */
  151. function create(context) {
  152. const {functions: configFunctions} = {
  153. functions: [],
  154. ...context.options[0],
  155. };
  156. const functions = [...configFunctions, ...lodashFromPairsFunctions];
  157. const sourceCode = context.getSourceCode();
  158. const listeners = {};
  159. const arrayReduce = new Map();
  160. for (const {selector, test, getProperty} of fixableArrayReduceCases) {
  161. listeners[selector] = node => {
  162. // If this listener exits without adding a fix, the `arrayReduceWithEmptyObject` listener
  163. // should still add it into the `arrayReduce` map. To be safer, add it here too.
  164. arrayReduce.set(node, undefined);
  165. const [callbackFunction] = node.arguments;
  166. if (!test(callbackFunction)) {
  167. return;
  168. }
  169. const [firstParameter] = callbackFunction.params;
  170. const variables = context.getDeclaredVariables(callbackFunction);
  171. const firstParameterVariable = variables.find(variable => variable.identifiers.length === 1 && variable.identifiers[0] === firstParameter);
  172. if (!firstParameterVariable || firstParameterVariable.references.length !== 1) {
  173. return;
  174. }
  175. arrayReduce.set(
  176. node,
  177. // The fix function
  178. fixReduceAssignOrSpread({
  179. sourceCode,
  180. node,
  181. property: getProperty(callbackFunction),
  182. }),
  183. );
  184. };
  185. }
  186. listeners[arrayReduceWithEmptyObject] = node => {
  187. if (!arrayReduce.has(node)) {
  188. arrayReduce.set(node, undefined);
  189. }
  190. };
  191. listeners['Program:exit'] = () => {
  192. for (const [node, fix] of arrayReduce.entries()) {
  193. context.report({
  194. node: node.callee.property,
  195. messageId: MESSAGE_ID_REDUCE,
  196. fix,
  197. });
  198. }
  199. };
  200. listeners[anyCall] = node => {
  201. if (!isNodeMatches(node, functions)) {
  202. return;
  203. }
  204. const functionName = functions.find(nameOrPath => isNodeMatchesNameOrPath(node, nameOrPath)).trim();
  205. context.report({
  206. node,
  207. messageId: MESSAGE_ID_FUNCTION,
  208. data: {functionName},
  209. fix: fixer => fixer.replaceText(node, 'Object.fromEntries'),
  210. });
  211. };
  212. return listeners;
  213. }
  214. const schema = [
  215. {
  216. type: 'object',
  217. additionalProperties: false,
  218. properties: {
  219. functions: {
  220. type: 'array',
  221. uniqueItems: true,
  222. },
  223. },
  224. },
  225. ];
  226. /** @type {import('eslint').Rule.RuleModule} */
  227. module.exports = {
  228. create,
  229. meta: {
  230. type: 'suggestion',
  231. docs: {
  232. description: 'Prefer using `Object.fromEntries(…)` to transform a list of key-value pairs into an object.',
  233. url: getDocumentationUrl(__filename),
  234. },
  235. fixable: 'code',
  236. schema,
  237. messages,
  238. },
  239. };