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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. 'use strict';
  2. const {isParenthesized, getStaticValue} = require('@eslint-community/eslint-utils');
  3. const {methodCallSelector} = require('./selectors/index.js');
  4. const escapeString = require('./utils/escape-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. }
  105. // Goal: `String(target).startsWith(pattern)`
  106. case FIX_TYPE_STRING_CASTING: {
  107. // `target` was a call argument, don't need check parentheses
  108. targetText = `String(${targetText})`;
  109. // `CallExpression` don't need add parentheses to call `.startsWith()`
  110. break;
  111. }
  112. // Goal: `target.startsWith(pattern)` or `target?.startsWith(pattern)`
  113. case FIX_TYPE_OPTIONAL_CHAINING: {
  114. // Optional chaining: `target.startsWith` => `target?.startsWith`
  115. yield fixer.replaceText(sourceCode.getTokenBefore(node.callee.property), '?.');
  116. }
  117. // Fallthrough
  118. default: {
  119. if (
  120. !isRegexParenthesized
  121. && !isTargetParenthesized
  122. && shouldAddParenthesesToMemberExpressionObject(target, sourceCode)
  123. ) {
  124. targetText = addParentheses(targetText);
  125. }
  126. }
  127. }
  128. // The regex literal always starts with `/` or `(`, so we don't need check ASI
  129. // Replace regex with string
  130. yield fixer.replaceText(regexNode, targetText);
  131. // `.test` => `.startsWith` / `.endsWith`
  132. yield fixer.replaceText(node.callee.property, method);
  133. // Replace argument with result.string
  134. yield fixer.replaceTextRange(getParenthesizedRange(target, sourceCode), escapeString(result.string));
  135. }
  136. if (isString || !isNonString) {
  137. problem.fix = fix;
  138. }
  139. if (!isString) {
  140. problem.suggest = [
  141. FIX_TYPE_STRING_CASTING,
  142. FIX_TYPE_OPTIONAL_CHAINING,
  143. FIX_TYPE_NULLISH_COALESCING,
  144. ].map(type => ({messageId: type, data: {method}, fix: fixer => fix(fixer, type)}));
  145. }
  146. return problem;
  147. },
  148. };
  149. };
  150. /** @type {import('eslint').Rule.RuleModule} */
  151. module.exports = {
  152. create,
  153. meta: {
  154. type: 'suggestion',
  155. docs: {
  156. description: 'Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`.',
  157. },
  158. fixable: 'code',
  159. hasSuggestions: true,
  160. messages,
  161. },
  162. };