prefer-string-slice.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. 'use strict';
  2. const {getStaticValue} = require('@eslint-community/eslint-utils');
  3. const {getParenthesizedText, getParenthesizedRange} = require('./utils/parentheses.js');
  4. const {methodCallSelector} = require('./selectors/index.js');
  5. const isNumber = require('./utils/is-number.js');
  6. const {replaceArgument} = require('./fix/index.js');
  7. const {isNumberLiteral} = require('./ast/index.js');
  8. const MESSAGE_ID_SUBSTR = 'substr';
  9. const MESSAGE_ID_SUBSTRING = 'substring';
  10. const messages = {
  11. [MESSAGE_ID_SUBSTR]: 'Prefer `String#slice()` over `String#substr()`.',
  12. [MESSAGE_ID_SUBSTRING]: 'Prefer `String#slice()` over `String#substring()`.',
  13. };
  14. const selector = methodCallSelector({
  15. methods: ['substr', 'substring'],
  16. includeOptionalMember: true,
  17. includeOptionalCall: true,
  18. });
  19. const getNumericValue = node => {
  20. if (isNumberLiteral(node)) {
  21. return node.value;
  22. }
  23. if (node.type === 'UnaryExpression' && node.operator === '-') {
  24. return -getNumericValue(node.argument);
  25. }
  26. };
  27. // This handles cases where the argument is very likely to be a number, such as `.substring('foo'.length)`.
  28. const isLengthProperty = node => (
  29. node?.type === 'MemberExpression'
  30. && node.computed === false
  31. && node.property.type === 'Identifier'
  32. && node.property.name === 'length'
  33. );
  34. function * fixSubstrArguments({node, fixer, context, abort}) {
  35. const argumentNodes = node.arguments;
  36. const [firstArgument, secondArgument] = argumentNodes;
  37. if (!secondArgument) {
  38. return;
  39. }
  40. const scope = context.getScope();
  41. const sourceCode = context.getSourceCode();
  42. const firstArgumentStaticResult = getStaticValue(firstArgument, scope);
  43. const secondArgumentRange = getParenthesizedRange(secondArgument, sourceCode);
  44. const replaceSecondArgument = text => replaceArgument(fixer, secondArgument, text, sourceCode);
  45. if (firstArgumentStaticResult?.value === 0) {
  46. if (isNumberLiteral(secondArgument) || isLengthProperty(secondArgument)) {
  47. return;
  48. }
  49. if (typeof getNumericValue(secondArgument) === 'number') {
  50. yield replaceSecondArgument(Math.max(0, getNumericValue(secondArgument)));
  51. return;
  52. }
  53. yield fixer.insertTextBeforeRange(secondArgumentRange, 'Math.max(0, ');
  54. yield fixer.insertTextAfterRange(secondArgumentRange, ')');
  55. return;
  56. }
  57. if (argumentNodes.every(node => isNumberLiteral(node))) {
  58. yield replaceSecondArgument(firstArgument.value + secondArgument.value);
  59. return;
  60. }
  61. if (argumentNodes.every(node => isNumber(node, context.getScope()))) {
  62. const firstArgumentText = getParenthesizedText(firstArgument, sourceCode);
  63. yield fixer.insertTextBeforeRange(secondArgumentRange, `${firstArgumentText} + `);
  64. return;
  65. }
  66. return abort();
  67. }
  68. function * fixSubstringArguments({node, fixer, context, abort}) {
  69. const sourceCode = context.getSourceCode();
  70. const [firstArgument, secondArgument] = node.arguments;
  71. const firstNumber = firstArgument ? getNumericValue(firstArgument) : undefined;
  72. const firstArgumentText = getParenthesizedText(firstArgument, sourceCode);
  73. const replaceFirstArgument = text => replaceArgument(fixer, firstArgument, text, sourceCode);
  74. if (!secondArgument) {
  75. if (isLengthProperty(firstArgument)) {
  76. return;
  77. }
  78. if (firstNumber !== undefined) {
  79. yield replaceFirstArgument(Math.max(0, firstNumber));
  80. return;
  81. }
  82. const firstArgumentRange = getParenthesizedRange(firstArgument, sourceCode);
  83. yield fixer.insertTextBeforeRange(firstArgumentRange, 'Math.max(0, ');
  84. yield fixer.insertTextAfterRange(firstArgumentRange, ')');
  85. return;
  86. }
  87. const secondNumber = getNumericValue(secondArgument);
  88. const secondArgumentText = getParenthesizedText(secondArgument, sourceCode);
  89. const replaceSecondArgument = text => replaceArgument(fixer, secondArgument, text, sourceCode);
  90. if (firstNumber !== undefined && secondNumber !== undefined) {
  91. const argumentsValue = [Math.max(0, firstNumber), Math.max(0, secondNumber)];
  92. if (firstNumber > secondNumber) {
  93. argumentsValue.reverse();
  94. }
  95. if (argumentsValue[0] !== firstNumber) {
  96. yield replaceFirstArgument(argumentsValue[0]);
  97. }
  98. if (argumentsValue[1] !== secondNumber) {
  99. yield replaceSecondArgument(argumentsValue[1]);
  100. }
  101. return;
  102. }
  103. if (firstNumber === 0 || secondNumber === 0) {
  104. yield replaceFirstArgument(0);
  105. yield replaceSecondArgument(`Math.max(0, ${firstNumber === 0 ? secondArgumentText : firstArgumentText})`);
  106. return;
  107. }
  108. // As values aren't Literal, we can not know whether secondArgument will become smaller than the first or not, causing an issue:
  109. // .substring(0, 2) and .substring(2, 0) returns the same result
  110. // .slice(0, 2) and .slice(2, 0) doesn't return the same result
  111. // There's also an issue with us now knowing whether the value will be negative or not, due to:
  112. // .substring() treats a negative number the same as it treats a zero.
  113. // The latter issue could be solved by wrapping all dynamic numbers in Math.max(0, <value>), but the resulting code would not be nice
  114. return abort();
  115. }
  116. /** @param {import('eslint').Rule.RuleContext} context */
  117. const create = context => ({
  118. [selector](node) {
  119. const method = node.callee.property.name;
  120. return {
  121. node,
  122. messageId: method,
  123. * fix(fixer, {abort}) {
  124. yield fixer.replaceText(node.callee.property, 'slice');
  125. if (node.arguments.length === 0) {
  126. return;
  127. }
  128. if (
  129. node.arguments.length > 2
  130. || node.arguments.some(node => node.type === 'SpreadElement')
  131. ) {
  132. return abort();
  133. }
  134. const fixArguments = method === 'substr' ? fixSubstrArguments : fixSubstringArguments;
  135. yield * fixArguments({node, fixer, context, abort});
  136. },
  137. };
  138. },
  139. });
  140. /** @type {import('eslint').Rule.RuleModule} */
  141. module.exports = {
  142. create,
  143. meta: {
  144. type: 'suggestion',
  145. docs: {
  146. description: 'Prefer `String#slice()` over `String#substr()` and `String#substring()`.',
  147. },
  148. fixable: 'code',
  149. messages,
  150. },
  151. };