123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- 'use strict';
- const {isParenthesized, getStaticValue} = require('eslint-utils');
- const {methodCallSelector} = require('./selectors/index.js');
- const quoteString = require('./utils/quote-string.js');
- const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add-parentheses-to-member-expression-object.js');
- const shouldAddParenthesesToLogicalExpressionChild = require('./utils/should-add-parentheses-to-logical-expression-child.js');
- const {getParenthesizedText, getParenthesizedRange} = require('./utils/parentheses.js');
- const MESSAGE_STARTS_WITH = 'prefer-starts-with';
- const MESSAGE_ENDS_WITH = 'prefer-ends-with';
- const FIX_TYPE_STRING_CASTING = 'useStringCasting';
- const FIX_TYPE_OPTIONAL_CHAINING = 'useOptionalChaining';
- const FIX_TYPE_NULLISH_COALESCING = 'useNullishCoalescing';
- const messages = {
- [MESSAGE_STARTS_WITH]: 'Prefer `String#startsWith()` over a regex with `^`.',
- [MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.',
- [FIX_TYPE_STRING_CASTING]: 'Convert to string `String(…).{{method}}()`.',
- [FIX_TYPE_OPTIONAL_CHAINING]: 'Use optional chaining `…?.{{method}}()`.',
- [FIX_TYPE_NULLISH_COALESCING]: 'Use nullish coalescing `(… ?? \'\').{{method}}()`.',
- };
- const doesNotContain = (string, characters) => characters.every(character => !string.includes(character));
- const isSimpleString = string => doesNotContain(
- string,
- ['^', '$', '+', '[', '{', '(', '\\', '.', '?', '*', '|'],
- );
- const addParentheses = text => `(${text})`;
- const regexTestSelector = [
- methodCallSelector({method: 'test', argumentsLength: 1}),
- '[callee.object.regex]',
- ].join('');
- const checkRegex = ({pattern, flags}) => {
- if (flags.includes('i') || flags.includes('m')) {
- return;
- }
- if (pattern.startsWith('^')) {
- const string = pattern.slice(1);
- if (isSimpleString(string)) {
- return {
- messageId: MESSAGE_STARTS_WITH,
- string,
- };
- }
- }
- if (pattern.endsWith('$')) {
- const string = pattern.slice(0, -1);
- if (isSimpleString(string)) {
- return {
- messageId: MESSAGE_ENDS_WITH,
- string,
- };
- }
- }
- };
- /** @param {import('eslint').Rule.RuleContext} context */
- const create = context => {
- const sourceCode = context.getSourceCode();
- return {
- [regexTestSelector](node) {
- const regexNode = node.callee.object;
- const {regex} = regexNode;
- const result = checkRegex(regex);
- if (!result) {
- return;
- }
- const [target] = node.arguments;
- const method = result.messageId === MESSAGE_STARTS_WITH ? 'startsWith' : 'endsWith';
- let isString = target.type === 'TemplateLiteral'
- || (
- target.type === 'CallExpression'
- && target.callee.type === 'Identifier'
- && target.callee.name === 'String'
- );
- let isNonString = false;
- if (!isString) {
- const staticValue = getStaticValue(target, context.getScope());
- if (staticValue) {
- isString = typeof staticValue.value === 'string';
- isNonString = !isString;
- }
- }
- const problem = {
- node,
- messageId: result.messageId,
- };
- function * fix(fixer, fixType) {
- let targetText = getParenthesizedText(target, sourceCode);
- const isRegexParenthesized = isParenthesized(regexNode, sourceCode);
- const isTargetParenthesized = isParenthesized(target, sourceCode);
- switch (fixType) {
- // Goal: `(target ?? '').startsWith(pattern)`
- case FIX_TYPE_NULLISH_COALESCING:
- if (
- !isTargetParenthesized
- && shouldAddParenthesesToLogicalExpressionChild(target, {operator: '??', property: 'left'})
- ) {
- targetText = addParentheses(targetText);
- }
- targetText += ' ?? \'\'';
- // `LogicalExpression` need add parentheses to call `.startsWith()`,
- // but if regex is parenthesized, we can reuse it
- if (!isRegexParenthesized) {
- targetText = addParentheses(targetText);
- }
- break;
- // Goal: `String(target).startsWith(pattern)`
- case FIX_TYPE_STRING_CASTING:
- // `target` was a call argument, don't need check parentheses
- targetText = `String(${targetText})`;
- // `CallExpression` don't need add parentheses to call `.startsWith()`
- break;
- // Goal: `target.startsWith(pattern)` or `target?.startsWith(pattern)`
- case FIX_TYPE_OPTIONAL_CHAINING:
- // Optional chaining: `target.startsWith` => `target?.startsWith`
- yield fixer.replaceText(sourceCode.getTokenBefore(node.callee.property), '?.');
- // Fallthrough
- default:
- if (
- !isRegexParenthesized
- && !isTargetParenthesized
- && shouldAddParenthesesToMemberExpressionObject(target, sourceCode)
- ) {
- targetText = addParentheses(targetText);
- }
- }
- // The regex literal always starts with `/` or `(`, so we don't need check ASI
- // Replace regex with string
- yield fixer.replaceText(regexNode, targetText);
- // `.test` => `.startsWith` / `.endsWith`
- yield fixer.replaceText(node.callee.property, method);
- // Replace argument with result.string
- yield fixer.replaceTextRange(getParenthesizedRange(target, sourceCode), quoteString(result.string));
- }
- if (isString || !isNonString) {
- problem.fix = fix;
- }
- if (!isString) {
- problem.suggest = [
- FIX_TYPE_STRING_CASTING,
- FIX_TYPE_OPTIONAL_CHAINING,
- FIX_TYPE_NULLISH_COALESCING,
- ].map(type => ({messageId: type, data: {method}, fix: fixer => fix(fixer, type)}));
- }
- return problem;
- },
- };
- };
- /** @type {import('eslint').Rule.RuleModule} */
- module.exports = {
- create,
- meta: {
- type: 'suggestion',
- docs: {
- description: 'Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`.',
- },
- fixable: 'code',
- hasSuggestions: true,
- messages,
- },
- };
|