prefer-at.js 9.6 KB

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