prefer-string-starts-ends-with.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. 'use strict';
  2. const {isParenthesized, getStaticValue} = require('eslint-utils');
  3. const {methodCallSelector} = require('./selectors/index.js');
  4. const quoteString = require('./utils/quote-string.js');
  5. const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add-parentheses-to-member-expression-object.js');
  6. const shouldAddParenthesesToLogicalExpressionChild = require('./utils/should-add-parentheses-to-logical-expression-child.js');
  7. const {getParenthesizedText, getParenthesizedRange} = require('./utils/parentheses.js');
  8. const MESSAGE_STARTS_WITH = 'prefer-starts-with';
  9. const MESSAGE_ENDS_WITH = 'prefer-ends-with';
  10. const FIX_TYPE_STRING_CASTING = 'useStringCasting';
  11. const FIX_TYPE_OPTIONAL_CHAINING = 'useOptionalChaining';
  12. const FIX_TYPE_NULLISH_COALESCING = 'useNullishCoalescing';
  13. const messages = {
  14. [MESSAGE_STARTS_WITH]: 'Prefer `String#startsWith()` over a regex with `^`.',
  15. [MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.',
  16. [FIX_TYPE_STRING_CASTING]: 'Convert to string `String(…).{{method}}()`.',
  17. [FIX_TYPE_OPTIONAL_CHAINING]: 'Use optional chaining `…?.{{method}}()`.',
  18. [FIX_TYPE_NULLISH_COALESCING]: 'Use nullish coalescing `(… ?? \'\').{{method}}()`.',
  19. };
  20. const doesNotContain = (string, characters) => characters.every(character => !string.includes(character));
  21. const isSimpleString = string => doesNotContain(
  22. string,
  23. ['^', '$', '+', '[', '{', '(', '\\', '.', '?', '*', '|'],
  24. );
  25. const addParentheses = text => `(${text})`;
  26. const regexTestSelector = [
  27. methodCallSelector({method: 'test', argumentsLength: 1}),
  28. '[callee.object.regex]',
  29. ].join('');
  30. const checkRegex = ({pattern, flags}) => {
  31. if (flags.includes('i') || flags.includes('m')) {
  32. return;
  33. }
  34. if (pattern.startsWith('^')) {
  35. const string = pattern.slice(1);
  36. if (isSimpleString(string)) {
  37. return {
  38. messageId: MESSAGE_STARTS_WITH,
  39. string,
  40. };
  41. }
  42. }
  43. if (pattern.endsWith('$')) {
  44. const string = pattern.slice(0, -1);
  45. if (isSimpleString(string)) {
  46. return {
  47. messageId: MESSAGE_ENDS_WITH,
  48. string,
  49. };
  50. }
  51. }
  52. };
  53. /** @param {import('eslint').Rule.RuleContext} context */
  54. const create = context => {
  55. const sourceCode = context.getSourceCode();
  56. return {
  57. [regexTestSelector](node) {
  58. const regexNode = node.callee.object;
  59. const {regex} = regexNode;
  60. const result = checkRegex(regex);
  61. if (!result) {
  62. return;
  63. }
  64. const [target] = node.arguments;
  65. const method = result.messageId === MESSAGE_STARTS_WITH ? 'startsWith' : 'endsWith';
  66. let isString = target.type === 'TemplateLiteral'
  67. || (
  68. target.type === 'CallExpression'
  69. && target.callee.type === 'Identifier'
  70. && target.callee.name === 'String'
  71. );
  72. let isNonString = false;
  73. if (!isString) {
  74. const staticValue = getStaticValue(target, context.getScope());
  75. if (staticValue) {
  76. isString = typeof staticValue.value === 'string';
  77. isNonString = !isString;
  78. }
  79. }
  80. const problem = {
  81. node,
  82. messageId: result.messageId,
  83. };
  84. function * fix(fixer, fixType) {
  85. let targetText = getParenthesizedText(target, sourceCode);
  86. const isRegexParenthesized = isParenthesized(regexNode, sourceCode);
  87. const isTargetParenthesized = isParenthesized(target, sourceCode);
  88. switch (fixType) {
  89. // Goal: `(target ?? '').startsWith(pattern)`
  90. case FIX_TYPE_NULLISH_COALESCING:
  91. if (
  92. !isTargetParenthesized
  93. && shouldAddParenthesesToLogicalExpressionChild(target, {operator: '??', property: 'left'})
  94. ) {
  95. targetText = addParentheses(targetText);
  96. }
  97. targetText += ' ?? \'\'';
  98. // `LogicalExpression` need add parentheses to call `.startsWith()`,
  99. // but if regex is parenthesized, we can reuse it
  100. if (!isRegexParenthesized) {
  101. targetText = addParentheses(targetText);
  102. }
  103. break;
  104. // Goal: `String(target).startsWith(pattern)`
  105. case FIX_TYPE_STRING_CASTING:
  106. // `target` was a call argument, don't need check parentheses
  107. targetText = `String(${targetText})`;
  108. // `CallExpression` don't need add parentheses to call `.startsWith()`
  109. break;
  110. // Goal: `target.startsWith(pattern)` or `target?.startsWith(pattern)`
  111. case FIX_TYPE_OPTIONAL_CHAINING:
  112. // Optional chaining: `target.startsWith` => `target?.startsWith`
  113. yield fixer.replaceText(sourceCode.getTokenBefore(node.callee.property), '?.');
  114. // Fallthrough
  115. default:
  116. if (
  117. !isRegexParenthesized
  118. && !isTargetParenthesized
  119. && shouldAddParenthesesToMemberExpressionObject(target, sourceCode)
  120. ) {
  121. targetText = addParentheses(targetText);
  122. }
  123. }
  124. // The regex literal always starts with `/` or `(`, so we don't need check ASI
  125. // Replace regex with string
  126. yield fixer.replaceText(regexNode, targetText);
  127. // `.test` => `.startsWith` / `.endsWith`
  128. yield fixer.replaceText(node.callee.property, method);
  129. // Replace argument with result.string
  130. yield fixer.replaceTextRange(getParenthesizedRange(target, sourceCode), quoteString(result.string));
  131. }
  132. if (isString || !isNonString) {
  133. problem.fix = fix;
  134. }
  135. if (!isString) {
  136. problem.suggest = [
  137. FIX_TYPE_STRING_CASTING,
  138. FIX_TYPE_OPTIONAL_CHAINING,
  139. FIX_TYPE_NULLISH_COALESCING,
  140. ].map(type => ({messageId: type, data: {method}, fix: fixer => fix(fixer, type)}));
  141. }
  142. return problem;
  143. },
  144. };
  145. };
  146. /** @type {import('eslint').Rule.RuleModule} */
  147. module.exports = {
  148. create,
  149. meta: {
  150. type: 'suggestion',
  151. docs: {
  152. description: 'Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`.',
  153. },
  154. fixable: 'code',
  155. hasSuggestions: true,
  156. messages,
  157. },
  158. };