'use strict'; const {isParenthesized, getStaticValue} = require('@eslint-community/eslint-utils'); const {methodCallSelector} = require('./selectors/index.js'); const escapeString = require('./utils/escape-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), escapeString(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, }, };