blob: 5b3e0283051cbcb4eb980ff92797ab6cab513efc [file] [log] [blame]
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const experimental_utils_1 = require("@typescript-eslint/experimental-utils");
const regexpp_1 = require("regexpp");
const util_1 = require("../util");
const EQ_OPERATORS = /^[=!]=/;
const regexpp = new regexpp_1.RegExpParser();
exports.default = util_1.createRule({
name: 'prefer-string-starts-ends-with',
defaultOptions: [],
meta: {
type: 'suggestion',
docs: {
description: 'Enforce the use of `String#startsWith` and `String#endsWith` instead of other equivalent methods of checking substrings',
category: 'Best Practices',
recommended: false,
requiresTypeChecking: true,
},
messages: {
preferStartsWith: "Use 'String#startsWith' method instead.",
preferEndsWith: "Use the 'String#endsWith' method instead.",
},
schema: [],
fixable: 'code',
},
create(context) {
const globalScope = context.getScope();
const sourceCode = context.getSourceCode();
const service = util_1.getParserServices(context);
const typeChecker = service.program.getTypeChecker();
/**
* Check if a given node is a string.
* @param node The node to check.
*/
function isStringType(node) {
const objectType = typeChecker.getTypeAtLocation(service.esTreeNodeToTSNodeMap.get(node));
return util_1.getTypeName(typeChecker, objectType) === 'string';
}
/**
* Check if a given node is a `Literal` node that is null.
* @param node The node to check.
*/
function isNull(node) {
const evaluated = util_1.getStaticValue(node, globalScope);
return evaluated != null && evaluated.value === null;
}
/**
* Check if a given node is a `Literal` node that is a given value.
* @param node The node to check.
* @param value The expected value of the `Literal` node.
*/
function isNumber(node, value) {
const evaluated = util_1.getStaticValue(node, globalScope);
return evaluated != null && evaluated.value === value;
}
/**
* Check if a given node is a `Literal` node that is a character.
* @param node The node to check.
* @param kind The method name to get a character.
*/
function isCharacter(node) {
const evaluated = util_1.getStaticValue(node, globalScope);
return (evaluated != null &&
typeof evaluated.value === 'string' &&
// checks if the string is a character long
evaluated.value[0] === evaluated.value);
}
/**
* Check if a given node is `==`, `===`, `!=`, or `!==`.
* @param node The node to check.
*/
function isEqualityComparison(node) {
return (node.type === experimental_utils_1.AST_NODE_TYPES.BinaryExpression &&
EQ_OPERATORS.test(node.operator));
}
/**
* Check if two given nodes are the same meaning.
* @param node1 A node to compare.
* @param node2 Another node to compare.
*/
function isSameTokens(node1, node2) {
const tokens1 = sourceCode.getTokens(node1);
const tokens2 = sourceCode.getTokens(node2);
if (tokens1.length !== tokens2.length) {
return false;
}
for (let i = 0; i < tokens1.length; ++i) {
const token1 = tokens1[i];
const token2 = tokens2[i];
if (token1.type !== token2.type || token1.value !== token2.value) {
return false;
}
}
return true;
}
/**
* Check if a given node is the expression of the length of a string.
*
* - If `length` property access of `expectedObjectNode`, it's `true`.
* E.g., `foo` → `foo.length` / `"foo"` → `"foo".length`
* - If `expectedObjectNode` is a string literal, `node` can be a number.
* E.g., `"foo"` → `3`
*
* @param node The node to check.
* @param expectedObjectNode The node which is expected as the receiver of `length` property.
*/
function isLengthExpression(node, expectedObjectNode) {
if (node.type === experimental_utils_1.AST_NODE_TYPES.MemberExpression) {
return (util_1.getPropertyName(node, globalScope) === 'length' &&
isSameTokens(node.object, expectedObjectNode));
}
const evaluatedLength = util_1.getStaticValue(node, globalScope);
const evaluatedString = util_1.getStaticValue(expectedObjectNode, globalScope);
return (evaluatedLength != null &&
evaluatedString != null &&
typeof evaluatedLength.value === 'number' &&
typeof evaluatedString.value === 'string' &&
evaluatedLength.value === evaluatedString.value.length);
}
/**
* Check if a given node is a negative index expression
*
* E.g. `s.slice(- <expr>)`, `s.substring(s.length - <expr>)`
*
* @param node The node to check.
* @param expectedIndexedNode The node which is expected as the receiver of index expression.
*/
function isNegativeIndexExpression(node, expectedIndexedNode) {
return ((node.type === experimental_utils_1.AST_NODE_TYPES.UnaryExpression &&
node.operator === '-') ||
(node.type === experimental_utils_1.AST_NODE_TYPES.BinaryExpression &&
node.operator === '-' &&
isLengthExpression(node.left, expectedIndexedNode)));
}
/**
* Check if a given node is the expression of the last index.
*
* E.g. `foo.length - 1`
*
* @param node The node to check.
* @param expectedObjectNode The node which is expected as the receiver of `length` property.
*/
function isLastIndexExpression(node, expectedObjectNode) {
return (node.type === experimental_utils_1.AST_NODE_TYPES.BinaryExpression &&
node.operator === '-' &&
isLengthExpression(node.left, expectedObjectNode) &&
isNumber(node.right, 1));
}
/**
* Get the range of the property of a given `MemberExpression` node.
*
* - `obj[foo]` → the range of `[foo]`
* - `obf.foo` → the range of `.foo`
* - `(obj).foo` → the range of `.foo`
*
* @param node The member expression node to get.
*/
function getPropertyRange(node) {
const dotOrOpenBracket = sourceCode.getTokenAfter(node.object, util_1.isNotClosingParenToken);
return [dotOrOpenBracket.range[0], node.range[1]];
}
/**
* Parse a given `RegExp` pattern to that string if it's a static string.
* @param pattern The RegExp pattern text to parse.
* @param uFlag The Unicode flag of the RegExp.
*/
function parseRegExpText(pattern, uFlag) {
// Parse it.
const ast = regexpp.parsePattern(pattern, undefined, undefined, uFlag);
if (ast.alternatives.length !== 1) {
return null;
}
// Drop `^`/`$` assertion.
const chars = ast.alternatives[0].elements;
const first = chars[0];
if (first.type === 'Assertion' && first.kind === 'start') {
chars.shift();
}
else {
chars.pop();
}
// Check if it can determine a unique string.
if (!chars.every(c => c.type === 'Character')) {
return null;
}
// To string.
return String.fromCodePoint(...chars.map(c => c.value));
}
/**
* Parse a given node if it's a `RegExp` instance.
* @param node The node to parse.
*/
function parseRegExp(node) {
const evaluated = util_1.getStaticValue(node, globalScope);
if (evaluated == null || !(evaluated.value instanceof RegExp)) {
return null;
}
const { source, flags } = evaluated.value;
const isStartsWith = source.startsWith('^');
const isEndsWith = source.endsWith('$');
if (isStartsWith === isEndsWith ||
flags.includes('i') ||
flags.includes('m')) {
return null;
}
const text = parseRegExpText(source, flags.includes('u'));
if (text == null) {
return null;
}
return { isEndsWith, isStartsWith, text };
}
function getLeftNode(node) {
if (node.type === experimental_utils_1.AST_NODE_TYPES.ChainExpression) {
return getLeftNode(node.expression);
}
let leftNode;
if (node.type === experimental_utils_1.AST_NODE_TYPES.CallExpression) {
leftNode = node.callee;
}
else {
leftNode = node;
}
if (leftNode.type !== experimental_utils_1.AST_NODE_TYPES.MemberExpression) {
throw new Error(`Expected a MemberExpression, got ${leftNode.type}`);
}
return leftNode;
}
/**
* Fix code with using the right operand as the search string.
* For example: `foo.slice(0, 3) === 'bar'` → `foo.startsWith('bar')`
* @param fixer The rule fixer.
* @param node The node which was reported.
* @param kind The kind of the report.
* @param isNegative The flag to fix to negative condition.
*/
function* fixWithRightOperand(fixer, node, kind, isNegative, isOptional) {
// left is CallExpression or MemberExpression.
const leftNode = getLeftNode(node.left);
const propertyRange = getPropertyRange(leftNode);
if (isNegative) {
yield fixer.insertTextBefore(node, '!');
}
yield fixer.replaceTextRange([propertyRange[0], node.right.range[0]], `${isOptional ? '?.' : '.'}${kind}sWith(`);
yield fixer.replaceTextRange([node.right.range[1], node.range[1]], ')');
}
/**
* Fix code with using the first argument as the search string.
* For example: `foo.indexOf('bar') === 0` → `foo.startsWith('bar')`
* @param fixer The rule fixer.
* @param node The node which was reported.
* @param kind The kind of the report.
* @param negative The flag to fix to negative condition.
*/
function* fixWithArgument(fixer, node, callNode, calleeNode, kind, negative, isOptional) {
if (negative) {
yield fixer.insertTextBefore(node, '!');
}
yield fixer.replaceTextRange(getPropertyRange(calleeNode), `${isOptional ? '?.' : '.'}${kind}sWith`);
yield fixer.removeRange([callNode.range[1], node.range[1]]);
}
function getParent(node) {
var _a;
return util_1.nullThrows(((_a = node.parent) === null || _a === void 0 ? void 0 : _a.type) === experimental_utils_1.AST_NODE_TYPES.ChainExpression
? node.parent.parent
: node.parent, util_1.NullThrowsReasons.MissingParent);
}
return {
// foo[0] === "a"
// foo.charAt(0) === "a"
// foo[foo.length - 1] === "a"
// foo.charAt(foo.length - 1) === "a"
[[
'BinaryExpression > MemberExpression.left[computed=true]',
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="charAt"][computed=false]',
'BinaryExpression > ChainExpression.left > MemberExpression[computed=true]',
'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="charAt"][computed=false]',
].join(', ')](node) {
let parentNode = getParent(node);
let indexNode = null;
if ((parentNode === null || parentNode === void 0 ? void 0 : parentNode.type) === experimental_utils_1.AST_NODE_TYPES.CallExpression) {
if (parentNode.arguments.length === 1) {
indexNode = parentNode.arguments[0];
}
parentNode = getParent(parentNode);
}
else {
indexNode = node.property;
}
if (indexNode == null ||
!isEqualityComparison(parentNode) ||
!isStringType(node.object)) {
return;
}
const isEndsWith = isLastIndexExpression(indexNode, node.object);
const isStartsWith = !isEndsWith && isNumber(indexNode, 0);
if (!isStartsWith && !isEndsWith) {
return;
}
const eqNode = parentNode;
context.report({
node: parentNode,
messageId: isStartsWith ? 'preferStartsWith' : 'preferEndsWith',
fix(fixer) {
// Don't fix if it can change the behavior.
if (!isCharacter(eqNode.right)) {
return null;
}
return fixWithRightOperand(fixer, eqNode, isStartsWith ? 'start' : 'end', eqNode.operator.startsWith('!'), node.optional);
},
});
},
// foo.indexOf('bar') === 0
[[
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="indexOf"][computed=false]',
'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="indexOf"][computed=false]',
].join(', ')](node) {
const callNode = getParent(node);
const parentNode = getParent(callNode);
if (callNode.arguments.length !== 1 ||
!isEqualityComparison(parentNode) ||
!isNumber(parentNode.right, 0) ||
!isStringType(node.object)) {
return;
}
context.report({
node: parentNode,
messageId: 'preferStartsWith',
fix(fixer) {
return fixWithArgument(fixer, parentNode, callNode, node, 'start', parentNode.operator.startsWith('!'), node.optional);
},
});
},
// foo.lastIndexOf('bar') === foo.length - 3
// foo.lastIndexOf(bar) === foo.length - bar.length
[[
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="lastIndexOf"][computed=false]',
'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="lastIndexOf"][computed=false]',
].join(', ')](node) {
const callNode = getParent(node);
const parentNode = getParent(callNode);
if (callNode.arguments.length !== 1 ||
!isEqualityComparison(parentNode) ||
parentNode.right.type !== experimental_utils_1.AST_NODE_TYPES.BinaryExpression ||
parentNode.right.operator !== '-' ||
!isLengthExpression(parentNode.right.left, node.object) ||
!isLengthExpression(parentNode.right.right, callNode.arguments[0]) ||
!isStringType(node.object)) {
return;
}
context.report({
node: parentNode,
messageId: 'preferEndsWith',
fix(fixer) {
return fixWithArgument(fixer, parentNode, callNode, node, 'end', parentNode.operator.startsWith('!'), node.optional);
},
});
},
// foo.match(/^bar/) === null
// foo.match(/bar$/) === null
[[
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="match"][computed=false]',
'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="match"][computed=false]',
].join(', ')](node) {
const callNode = getParent(node);
const parentNode = getParent(callNode);
if (!isEqualityComparison(parentNode) ||
!isNull(parentNode.right) ||
!isStringType(node.object)) {
return;
}
const parsed = callNode.arguments.length === 1
? parseRegExp(callNode.arguments[0])
: null;
if (parsed == null) {
return;
}
const { isStartsWith, text } = parsed;
context.report({
node: callNode,
messageId: isStartsWith ? 'preferStartsWith' : 'preferEndsWith',
*fix(fixer) {
if (!parentNode.operator.startsWith('!')) {
yield fixer.insertTextBefore(parentNode, '!');
}
yield fixer.replaceTextRange(getPropertyRange(node), `${node.optional ? '?.' : '.'}${isStartsWith ? 'start' : 'end'}sWith`);
yield fixer.replaceText(callNode.arguments[0], JSON.stringify(text));
yield fixer.removeRange([callNode.range[1], parentNode.range[1]]);
},
});
},
// foo.slice(0, 3) === 'bar'
// foo.slice(-3) === 'bar'
// foo.slice(-3, foo.length) === 'bar'
// foo.substring(0, 3) === 'bar'
// foo.substring(foo.length - 3) === 'bar'
// foo.substring(foo.length - 3, foo.length) === 'bar'
[[
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="slice"][computed=false]',
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="substring"][computed=false]',
'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="slice"][computed=false]',
'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="substring"][computed=false]',
].join(', ')](node) {
const callNode = getParent(node);
const parentNode = getParent(callNode);
if (!isEqualityComparison(parentNode) || !isStringType(node.object)) {
return;
}
const isEndsWith = (callNode.arguments.length === 1 ||
(callNode.arguments.length === 2 &&
isLengthExpression(callNode.arguments[1], node.object))) &&
isNegativeIndexExpression(callNode.arguments[0], node.object);
const isStartsWith = !isEndsWith &&
callNode.arguments.length === 2 &&
isNumber(callNode.arguments[0], 0);
if (!isStartsWith && !isEndsWith) {
return;
}
const eqNode = parentNode;
const negativeIndexSupported = node.property.name === 'slice';
context.report({
node: parentNode,
messageId: isStartsWith ? 'preferStartsWith' : 'preferEndsWith',
fix(fixer) {
// Don't fix if it can change the behavior.
if (eqNode.operator.length === 2 &&
(eqNode.right.type !== experimental_utils_1.AST_NODE_TYPES.Literal ||
typeof eqNode.right.value !== 'string')) {
return null;
}
// code being checked is likely mistake:
// unequal length of strings being checked for equality
// or reliant on behavior of substring (negative indices interpreted as 0)
if (isStartsWith) {
if (!isLengthExpression(callNode.arguments[1], eqNode.right)) {
return null;
}
}
else {
const posNode = callNode.arguments[0];
const posNodeIsAbsolutelyValid = (posNode.type === experimental_utils_1.AST_NODE_TYPES.BinaryExpression &&
posNode.operator === '-' &&
isLengthExpression(posNode.left, node.object) &&
isLengthExpression(posNode.right, eqNode.right)) ||
(negativeIndexSupported &&
posNode.type === experimental_utils_1.AST_NODE_TYPES.UnaryExpression &&
posNode.operator === '-' &&
isLengthExpression(posNode.argument, eqNode.right));
if (!posNodeIsAbsolutelyValid) {
return null;
}
}
return fixWithRightOperand(fixer, parentNode, isStartsWith ? 'start' : 'end', parentNode.operator.startsWith('!'), node.optional);
},
});
},
// /^bar/.test(foo)
// /bar$/.test(foo)
'CallExpression > MemberExpression.callee[property.name="test"][computed=false]'(node) {
const callNode = getParent(node);
const parsed = callNode.arguments.length === 1 ? parseRegExp(node.object) : null;
if (parsed == null) {
return;
}
const { isStartsWith, text } = parsed;
const messageId = isStartsWith ? 'preferStartsWith' : 'preferEndsWith';
const methodName = isStartsWith ? 'startsWith' : 'endsWith';
context.report({
node: callNode,
messageId,
*fix(fixer) {
const argNode = callNode.arguments[0];
const needsParen = argNode.type !== experimental_utils_1.AST_NODE_TYPES.Literal &&
argNode.type !== experimental_utils_1.AST_NODE_TYPES.TemplateLiteral &&
argNode.type !== experimental_utils_1.AST_NODE_TYPES.Identifier &&
argNode.type !== experimental_utils_1.AST_NODE_TYPES.MemberExpression &&
argNode.type !== experimental_utils_1.AST_NODE_TYPES.CallExpression;
yield fixer.removeRange([callNode.range[0], argNode.range[0]]);
if (needsParen) {
yield fixer.insertTextBefore(argNode, '(');
yield fixer.insertTextAfter(argNode, ')');
}
yield fixer.insertTextAfter(argNode, `${node.optional ? '?.' : '.'}${methodName}(${JSON.stringify(text)}`);
},
});
},
};
},
});
//# sourceMappingURL=prefer-string-starts-ends-with.js.map