securityos/node_modules/eslint-plugin-unicorn/rules/prefer-string-starts-ends-w...

193 lines
5.6 KiB
JavaScript

'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,
},
};