prefer-at.js 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. 'use strict';
  2. const {isOpeningBracketToken, isClosingBracketToken, getStaticValue} = require('eslint-utils');
  3. const isLiteralValue = require('./utils/is-literal-value.js');
  4. const {
  5. isParenthesized,
  6. getParenthesizedRange,
  7. getParenthesizedText,
  8. } = require('./utils/parentheses.js');
  9. const {isNodeMatchesNameOrPath} = require('./utils/is-node-matches.js');
  10. const needsSemicolon = require('./utils/needs-semicolon.js');
  11. const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add-parentheses-to-member-expression-object.js');
  12. const isLeftHandSide = require('./utils/is-left-hand-side.js');
  13. const {
  14. getNegativeIndexLengthNode,
  15. removeLengthNode,
  16. } = require('./shared/negative-index.js');
  17. const {methodCallSelector, callExpressionSelector, notLeftHandSideSelector} = require('./selectors/index.js');
  18. const {removeMemberExpressionProperty, removeMethodCall} = require('./fix/index.js');
  19. const MESSAGE_ID_NEGATIVE_INDEX = 'negative-index';
  20. const MESSAGE_ID_INDEX = 'index';
  21. const MESSAGE_ID_STRING_CHAR_AT_NEGATIVE = 'string-char-at-negative';
  22. const MESSAGE_ID_STRING_CHAR_AT = 'string-char-at';
  23. const MESSAGE_ID_SLICE = 'slice';
  24. const MESSAGE_ID_GET_LAST_FUNCTION = 'get-last-function';
  25. const SUGGESTION_ID = 'use-at';
  26. const messages = {
  27. [MESSAGE_ID_NEGATIVE_INDEX]: 'Prefer `.at(…)` over `[….length - index]`.',
  28. [MESSAGE_ID_INDEX]: 'Prefer `.at(…)` over index access.',
  29. [MESSAGE_ID_STRING_CHAR_AT_NEGATIVE]: 'Prefer `String#at(…)` over `String#charAt(….length - index)`.',
  30. [MESSAGE_ID_STRING_CHAR_AT]: 'Prefer `String#at(…)` over `String#charAt(…)`.',
  31. [MESSAGE_ID_SLICE]: 'Prefer `.at(…)` over the first element from `.slice(…)`.',
  32. [MESSAGE_ID_GET_LAST_FUNCTION]: 'Prefer `.at(-1)` over `{{description}}(…)` to get the last element.',
  33. [SUGGESTION_ID]: 'Use `.at(…)`.',
  34. };
  35. const indexAccess = [
  36. 'MemberExpression',
  37. '[optional!=true]',
  38. '[computed!=false]',
  39. notLeftHandSideSelector(),
  40. ].join('');
  41. const sliceCall = methodCallSelector({method: 'slice', minimumArguments: 1, maximumArguments: 2});
  42. const stringCharAt = methodCallSelector({method: 'charAt', argumentsLength: 1});
  43. const isLiteralNegativeInteger = node =>
  44. node.type === 'UnaryExpression'
  45. && node.prefix
  46. && node.operator === '-'
  47. && node.argument.type === 'Literal'
  48. && Number.isInteger(node.argument.value)
  49. && node.argument.value > 0;
  50. const isZeroIndexAccess = node => {
  51. const {parent} = node;
  52. return parent.type === 'MemberExpression'
  53. && !parent.optional
  54. && parent.computed
  55. && parent.object === node
  56. && isLiteralValue(parent.property, 0);
  57. };
  58. const isArrayPopOrShiftCall = (node, method) => {
  59. const {parent} = node;
  60. return parent.type === 'MemberExpression'
  61. && !parent.optional
  62. && !parent.computed
  63. && parent.object === node
  64. && parent.property.type === 'Identifier'
  65. && parent.property.name === method
  66. && parent.parent.type === 'CallExpression'
  67. && parent.parent.callee === parent
  68. && !parent.parent.optional
  69. && parent.parent.arguments.length === 0;
  70. };
  71. const isArrayPopCall = node => isArrayPopOrShiftCall(node, 'pop');
  72. const isArrayShiftCall = node => isArrayPopOrShiftCall(node, 'shift');
  73. function checkSliceCall(node) {
  74. const sliceArgumentsLength = node.arguments.length;
  75. const [startIndexNode, endIndexNode] = node.arguments;
  76. if (!isLiteralNegativeInteger(startIndexNode)) {
  77. return;
  78. }
  79. let firstElementGetMethod = '';
  80. if (isZeroIndexAccess(node)) {
  81. if (isLeftHandSide(node.parent)) {
  82. return;
  83. }
  84. firstElementGetMethod = 'zero-index';
  85. } else if (isArrayShiftCall(node)) {
  86. firstElementGetMethod = 'shift';
  87. } else if (isArrayPopCall(node)) {
  88. firstElementGetMethod = 'pop';
  89. }
  90. if (!firstElementGetMethod) {
  91. return;
  92. }
  93. const startIndex = -startIndexNode.argument.value;
  94. if (sliceArgumentsLength === 1) {
  95. if (
  96. firstElementGetMethod === 'zero-index'
  97. || firstElementGetMethod === 'shift'
  98. || (startIndex === -1 && firstElementGetMethod === 'pop')
  99. ) {
  100. return {safeToFix: true, firstElementGetMethod};
  101. }
  102. return;
  103. }
  104. if (
  105. isLiteralNegativeInteger(endIndexNode)
  106. && -endIndexNode.argument.value === startIndex + 1
  107. ) {
  108. return {safeToFix: true, firstElementGetMethod};
  109. }
  110. if (firstElementGetMethod === 'pop') {
  111. return;
  112. }
  113. return {safeToFix: false, firstElementGetMethod};
  114. }
  115. const lodashLastFunctions = [
  116. '_.last',
  117. 'lodash.last',
  118. 'underscore.last',
  119. ];
  120. /** @param {import('eslint').Rule.RuleContext} context */
  121. function create(context) {
  122. const {
  123. getLastElementFunctions,
  124. checkAllIndexAccess,
  125. } = {
  126. getLastElementFunctions: [],
  127. checkAllIndexAccess: false,
  128. ...context.options[0],
  129. };
  130. const getLastFunctions = [...getLastElementFunctions, ...lodashLastFunctions];
  131. const sourceCode = context.getSourceCode();
  132. return {
  133. [indexAccess](node) {
  134. const indexNode = node.property;
  135. const lengthNode = getNegativeIndexLengthNode(indexNode, node.object);
  136. if (!lengthNode) {
  137. if (!checkAllIndexAccess) {
  138. return;
  139. }
  140. // Only if we are sure it's an positive integer
  141. const staticValue = getStaticValue(indexNode, context.getScope());
  142. if (!staticValue || !Number.isInteger(staticValue.value) || staticValue.value < 0) {
  143. return;
  144. }
  145. }
  146. return {
  147. node: indexNode,
  148. messageId: lengthNode ? MESSAGE_ID_NEGATIVE_INDEX : MESSAGE_ID_INDEX,
  149. * fix(fixer) {
  150. if (lengthNode) {
  151. yield removeLengthNode(lengthNode, fixer, sourceCode);
  152. }
  153. const openingBracketToken = sourceCode.getTokenBefore(indexNode, isOpeningBracketToken);
  154. yield fixer.replaceText(openingBracketToken, '.at(');
  155. const closingBracketToken = sourceCode.getTokenAfter(indexNode, isClosingBracketToken);
  156. yield fixer.replaceText(closingBracketToken, ')');
  157. },
  158. };
  159. },
  160. [stringCharAt](node) {
  161. const [indexNode] = node.arguments;
  162. const lengthNode = getNegativeIndexLengthNode(indexNode, node.callee.object);
  163. // `String#charAt` don't care about index value, we assume it's always number
  164. if (!lengthNode && !checkAllIndexAccess) {
  165. return;
  166. }
  167. return {
  168. node: indexNode,
  169. messageId: lengthNode ? MESSAGE_ID_STRING_CHAR_AT_NEGATIVE : MESSAGE_ID_STRING_CHAR_AT,
  170. suggest: [{
  171. messageId: SUGGESTION_ID,
  172. * fix(fixer) {
  173. if (lengthNode) {
  174. yield removeLengthNode(lengthNode, fixer, sourceCode);
  175. }
  176. yield fixer.replaceText(node.callee.property, 'at');
  177. },
  178. }],
  179. };
  180. },
  181. [sliceCall](sliceCall) {
  182. const result = checkSliceCall(sliceCall);
  183. if (!result) {
  184. return;
  185. }
  186. const {safeToFix, firstElementGetMethod} = result;
  187. /** @param {import('eslint').Rule.RuleFixer} fixer */
  188. function * fix(fixer) {
  189. // `.slice` to `.at`
  190. yield fixer.replaceText(sliceCall.callee.property, 'at');
  191. // Remove extra arguments
  192. if (sliceCall.arguments.length !== 1) {
  193. const [, start] = getParenthesizedRange(sliceCall.arguments[0], sourceCode);
  194. const [end] = sourceCode.getLastToken(sliceCall).range;
  195. yield fixer.removeRange([start, end]);
  196. }
  197. // Remove `[0]`, `.shift()`, or `.pop()`
  198. if (firstElementGetMethod === 'zero-index') {
  199. yield removeMemberExpressionProperty(fixer, sliceCall.parent, sourceCode);
  200. } else {
  201. yield * removeMethodCall(fixer, sliceCall.parent.parent, sourceCode);
  202. }
  203. }
  204. const problem = {
  205. node: sliceCall.callee.property,
  206. messageId: MESSAGE_ID_SLICE,
  207. };
  208. if (safeToFix) {
  209. problem.fix = fix;
  210. } else {
  211. problem.suggest = [{messageId: SUGGESTION_ID, fix}];
  212. }
  213. return problem;
  214. },
  215. [callExpressionSelector({argumentsLength: 1})](node) {
  216. const matchedFunction = getLastFunctions.find(nameOrPath => isNodeMatchesNameOrPath(node.callee, nameOrPath));
  217. if (!matchedFunction) {
  218. return;
  219. }
  220. return {
  221. node: node.callee,
  222. messageId: MESSAGE_ID_GET_LAST_FUNCTION,
  223. data: {description: matchedFunction.trim()},
  224. fix(fixer) {
  225. const [array] = node.arguments;
  226. let fixed = getParenthesizedText(array, sourceCode);
  227. if (
  228. !isParenthesized(array, sourceCode)
  229. && shouldAddParenthesesToMemberExpressionObject(array, sourceCode)
  230. ) {
  231. fixed = `(${fixed})`;
  232. }
  233. fixed = `${fixed}.at(-1)`;
  234. const tokenBefore = sourceCode.getTokenBefore(node);
  235. if (needsSemicolon(tokenBefore, sourceCode, fixed)) {
  236. fixed = `;${fixed}`;
  237. }
  238. return fixer.replaceText(node, fixed);
  239. },
  240. };
  241. },
  242. };
  243. }
  244. const schema = [
  245. {
  246. type: 'object',
  247. additionalProperties: false,
  248. properties: {
  249. getLastElementFunctions: {
  250. type: 'array',
  251. uniqueItems: true,
  252. },
  253. checkAllIndexAccess: {
  254. type: 'boolean',
  255. default: false,
  256. },
  257. },
  258. },
  259. ];
  260. /** @type {import('eslint').Rule.RuleModule} */
  261. module.exports = {
  262. create,
  263. meta: {
  264. type: 'suggestion',
  265. docs: {
  266. description: 'Prefer `.at()` method for index access and `String#charAt()`.',
  267. },
  268. fixable: 'code',
  269. hasSuggestions: true,
  270. schema,
  271. messages,
  272. },
  273. };