β¬ οΈ Back to Table of Contents
π prefer-string-starts-ends-with¶
π Analysis Summary¶
| Metric | Count |
|---|---|
| π§ Functions | 17 |
| π¦ Imports | 15 |
| π Variables & Constants | 2 |
| π Type Aliases | 3 |
π Table of Contents¶
π οΈ File Location:¶
π packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts
π€ Default Export¶
| Property | Value |
|---|---|
name |
'prefer-string-starts-ends-with' |
meta.type |
'suggestion' |
meta.docs.description |
'Enforce using String#startsWith and String#endsWith over other equivalent methods of checking substrings' |
meta.docs.recommended |
'stylistic' |
meta.docs.requiresTypeChecking |
true |
meta.fixable |
'code' |
meta.messages.preferEndsWith |
"Use the 'String#endsWith' method instead." |
meta.messages.preferStartsWith |
"Use 'String#startsWith' method instead." |
meta.schema |
[ { type: 'object', additionalProperties: false, properties: { allowSingleElementEquality: { type: 'string', descript... |
defaultOptions |
[{ allowSingleElementEquality: 'never' }] |
Entry point: create β documented under Functions.
π¦ Imports¶
| Name | Source |
|---|---|
NodeWithParent |
@typescript-eslint/utils |
TSESLint |
@typescript-eslint/utils |
TSESTree |
@typescript-eslint/utils |
RegExpParser |
@eslint-community/regexpp |
AST_NODE_TYPES |
@typescript-eslint/utils |
createRule |
../util |
getParserServices |
../util |
getPropertyName |
../util |
getStaticValue |
../util |
getTypeName |
../util |
isNotClosingParenToken |
../util |
isStaticMemberAccessOfValue |
../util |
nullThrows |
../util |
NullThrowsReasons |
../util |
skipChainExpression |
../util |
Variables & Constants¶
| Name | Type | Kind | Value | Exported |
|---|---|---|---|---|
EQ_OPERATORS |
RegExp |
const | /^[=!]=/ |
β |
regexpp |
any |
const | new RegExpParser() |
β |
Functions¶
create(context: any, [{ allowSingleElementEquality }]: any): { [x: string]: (node: TSESTree.MemberExpression) => void; '⦶
Parameters:
contextany[{ allowSingleElementEquality }]any
Returns: { [x: string]: (node: TSESTree.MemberExpression) => void; 'CallExpression > MemberExpression.callee[property.name="test"][computed=false]'(node: TSESTree.MemberExpression): void; }
Calls:
context.sourceCode.getScopegetParserServices (from ../util)services.program.getTypeCheckerservices.getTypeAtLocationgetTypeName (from ../util)getStaticValue (from ../util)EQ_OPERATORS.testcontext.sourceCode.getTokensgetPropertyName (from ../util)isSameTokensisLengthExpressionisNumbernullThrows (from ../util)context.sourceCode.getTokenAfterNullThrowsReasons.MissingTokenregexpp.parsePatternchars.shiftchars.popchars.everyString.fromCodePointchars.mapsource.startsWith/[^\\](\\\\)*\$$/.testflags.includesparseRegExpTextskipChainExpression (from ../util)getLeftNodegetPropertyRangefixer.insertTextBeforefixer.replaceTextRangefixer.removeRange[ '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]', ].joingetParentisEqualityComparisonisStringTypeisLastIndexExpressioncontext.reportisCharacterfixWithRightOperandeqNode.operator.startsWith[ 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="indexOf"][computed=false]', 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="indexOf"][computed=false]', ].joinfixWithArgumentparentNode.operator.startsWith[ 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="lastIndexOf"][computed=false]', 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="lastIndexOf"][computed=false]', ].join[ 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="match"][computed=false]', 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="match"][computed=false]', ].joinisNullparseRegExpfixer.replaceTextJSON.stringify[ 'BinaryExpression > CallExpression.left > MemberExpression', 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression', ].joinisStaticMemberAccessOfValue (from ../util)isLengthAheadOfEndfixer.insertTextAfter
Internal Comments:
/**
* Check if a given node is a string.
* @param node The node to check.
*/
/**
* Check if a given node is a `Literal` node that is null.
* @param node The node to check.
*/
/**
* 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.
*/
/**
* Check if a given node is a `Literal` node that is a character.
* @param node The node to check.
*/
// checks if the string is a character long (x4)
/**
* Check if a given node is `==`, `===`, `!=`, or `!==`.
* @param node The node to check.
*/
/**
* Check if two given nodes are the same meaning.
* @param node1 A node to compare.
* @param node2 Another node to compare.
*/
/**
* 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.
*/
/**
* Returns true if `node` is `-substring.length` or
* `parentString.length - substring.length`
*/
/**
* 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.
*/
/**
* 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.
*/
/**
* Parse a given `RegExp` pattern to that string if it's a static string.
* @param pattern The RegExp pattern text to parse.
* @param unicode Whether the RegExp is unicode.
*/
// Parse it. (x2)
// Drop `^`/`$` assertion. (x2)
// Check if it can determine a unique string.
// To string.
/**
* Parse a given node if it's a `RegExp` instance.
* @param node The node to parse.
*/
// ends with a $ preceded by an even number of backslashes (or zero) (x2)
/**
* 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.
*/
// left is CallExpression or MemberExpression. (x2)
/**
* 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.
*/
// foo[0] === "a" (x2)
// foo.charAt(0) === "a" (x2)
// foo[foo.length - 1] === "a" (x2)
// foo.charAt(foo.length - 1) === "a" (x2)
// Don't fix if it can change the behavior. (x2)
// foo.indexOf('bar') === 0 (x2)
// foo.lastIndexOf('bar') === foo.length - 3 (x2)
// foo.lastIndexOf(bar) === foo.length - bar.length (x2)
// foo.match(/^bar/) === null (x2)
// foo.match(/bar$/) === null (x2)
// foo.slice(0, 3) === 'bar' (x2)
// foo.slice(-3) === 'bar' (x2)
// foo.slice(-3, foo.length) === 'bar' (x2)
// foo.substring(0, 3) === 'bar' (x2)
// foo.substring(foo.length - 3) === 'bar' (x2)
// foo.substring(foo.length - 3, foo.length) === 'bar' (x2)
// foo.slice(-bar.length) === bar (x2)
// foo.slice(foo.length - bar.length) === bar (x2)
// foo.slice(0, bar.length) === bar (x3)
// foo.slice(foo.length - bar.length, foo.length) === bar (x2)
// foo.slice(foo.length - bar.length, 0) === bar (x2)
// foo.slice(-bar.length, foo.length) === bar (x2)
// foo.slice(-bar.length, 0) === bar (x2)
// 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)
// /^bar/.test(foo) (x2)
// /bar$/.test(foo) (x2)
Code
create(context, [{ allowSingleElementEquality }]) {
const globalScope = context.sourceCode.getScope(context.sourceCode.ast);
const services = getParserServices(context);
const checker = services.program.getTypeChecker();
/**
* Check if a given node is a string.
* @param node The node to check.
*/
function isStringType(node: TSESTree.Expression): boolean {
const objectType = services.getTypeAtLocation(node);
return getTypeName(checker, objectType) === 'string';
}
/**
* Check if a given node is a `Literal` node that is null.
* @param node The node to check.
*/
function isNull(node: TSESTree.Node): node is TSESTree.Literal {
const evaluated = 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: TSESTree.Node,
value: number,
): node is TSESTree.Literal {
const evaluated = getStaticValue(node, globalScope);
return evaluated?.value === value;
}
/**
* Check if a given node is a `Literal` node that is a character.
* @param node The node to check.
*/
function isCharacter(node: TSESTree.Node): node is TSESTree.Literal {
const evaluated = 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: TSESTree.Node,
): node is TSESTree.BinaryExpression {
return (
node.type === 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: TSESTree.Node, node2: TSESTree.Node): boolean {
const tokens1 = context.sourceCode.getTokens(node1);
const tokens2 = context.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: TSESTree.Node,
expectedObjectNode: TSESTree.Node,
): boolean {
if (node.type === AST_NODE_TYPES.MemberExpression) {
return (
getPropertyName(node, globalScope) === 'length' &&
isSameTokens(node.object, expectedObjectNode)
);
}
const evaluatedLength = getStaticValue(node, globalScope);
const evaluatedString = getStaticValue(expectedObjectNode, globalScope);
return (
evaluatedLength != null &&
evaluatedString != null &&
typeof evaluatedLength.value === 'number' &&
typeof evaluatedString.value === 'string' &&
evaluatedLength.value === evaluatedString.value.length
);
}
/**
* Returns true if `node` is `-substring.length` or
* `parentString.length - substring.length`
*/
function isLengthAheadOfEnd(
node: TSESTree.Node,
substring: TSESTree.Node,
parentString: TSESTree.Node,
): boolean {
return (
(node.type === AST_NODE_TYPES.UnaryExpression &&
node.operator === '-' &&
isLengthExpression(node.argument, substring)) ||
(node.type === AST_NODE_TYPES.BinaryExpression &&
node.operator === '-' &&
isLengthExpression(node.left, parentString) &&
isLengthExpression(node.right, substring))
);
}
/**
* 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: TSESTree.Node,
expectedObjectNode: TSESTree.Node,
): boolean {
return (
node.type === 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: TSESTree.MemberExpression,
): [number, number] {
const dotOrOpenBracket = nullThrows(
context.sourceCode.getTokenAfter(node.object, isNotClosingParenToken),
NullThrowsReasons.MissingToken('closing parenthesis', 'member'),
);
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 unicode Whether the RegExp is unicode.
*/
function parseRegExpText(pattern: string, unicode: boolean): string | null {
// Parse it.
const ast = regexpp.parsePattern(pattern, undefined, undefined, {
unicode,
});
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: TSESTree.Node,
): { isEndsWith: boolean; isStartsWith: boolean; text: string } | null {
const evaluated = getStaticValue(node, globalScope);
if (evaluated == null || !(evaluated.value instanceof RegExp)) {
return null;
}
const { flags, source } = evaluated.value;
const isStartsWith = source.startsWith('^');
// ends with a $ preceded by an even number of backslashes (or zero)
const isEndsWith = /[^\\](\\\\)*\$$/.test(source);
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(
init: TSESTree.Expression | TSESTree.PrivateIdentifier,
): TSESTree.MemberExpression {
const node = skipChainExpression(init);
const leftNode =
node.type === AST_NODE_TYPES.CallExpression ? node.callee : node;
if (leftNode.type !== 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: TSESLint.RuleFixer,
node: TSESTree.BinaryExpression,
kind: 'end' | 'start',
isNegative: boolean,
isOptional: boolean,
): IterableIterator<TSESLint.RuleFix> {
// 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: TSESLint.RuleFixer,
node: TSESTree.BinaryExpression,
callNode: TSESTree.CallExpression,
calleeNode: TSESTree.MemberExpression,
kind: 'end' | 'start',
negative: boolean,
isOptional: boolean,
): IterableIterator<TSESLint.RuleFix> {
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: NodeWithParent): TSESTree.Node {
return node.parent.type === AST_NODE_TYPES.ChainExpression
? node.parent.parent
: node.parent;
}
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: TSESTree.MemberExpression): void {
let parentNode = getParent(node);
let indexNode: TSESTree.Node | null = null;
if (parentNode.type === 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);
if (allowSingleElementEquality === 'always' && isEndsWith) {
return;
}
const isStartsWith = !isEndsWith && isNumber(indexNode, 0);
if (
(allowSingleElementEquality === 'always' && isStartsWith) ||
(!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: TSESTree.MemberExpression): void {
const callNode = getParent(node) as TSESTree.CallExpression;
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: TSESTree.MemberExpression): void {
const callNode = getParent(node) as TSESTree.CallExpression;
const parentNode = getParent(callNode);
if (
callNode.arguments.length !== 1 ||
!isEqualityComparison(parentNode) ||
parentNode.right.type !== 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: TSESTree.MemberExpression): void {
const callNode = getParent(node) as TSESTree.CallExpression;
const parentNode = getParent(callNode) as TSESTree.BinaryExpression;
if (!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',
'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression',
].join(', ')](node: TSESTree.MemberExpression): void {
if (!isStaticMemberAccessOfValue(node, context, 'slice', 'substring')) {
return;
}
const callNode = getParent(node) as TSESTree.CallExpression;
const parentNode = getParent(callNode);
if (!isEqualityComparison(parentNode) || !isStringType(node.object)) {
return;
}
let isEndsWith = false;
let isStartsWith = false;
if (callNode.arguments.length === 1) {
if (
// foo.slice(-bar.length) === bar
// foo.slice(foo.length - bar.length) === bar
isLengthAheadOfEnd(
callNode.arguments[0],
parentNode.right,
node.object,
)
) {
isEndsWith = true;
}
} else if (callNode.arguments.length === 2) {
if (
// foo.slice(0, bar.length) === bar
isNumber(callNode.arguments[0], 0) &&
isLengthExpression(callNode.arguments[1], parentNode.right)
) {
isStartsWith = true;
} else if (
// foo.slice(foo.length - bar.length, foo.length) === bar
// foo.slice(foo.length - bar.length, 0) === bar
// foo.slice(-bar.length, foo.length) === bar
// foo.slice(-bar.length, 0) === bar
(isLengthExpression(callNode.arguments[1], node.object) ||
isNumber(callNode.arguments[1], 0)) &&
isLengthAheadOfEnd(
callNode.arguments[0],
parentNode.right,
node.object,
)
) {
isEndsWith = true;
}
}
if (!isStartsWith && !isEndsWith) {
return;
}
const eqNode = parentNode;
const negativeIndexSupported =
(node.property as TSESTree.Identifier).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 !== 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 === AST_NODE_TYPES.BinaryExpression &&
posNode.operator === '-' &&
isLengthExpression(posNode.left, node.object) &&
isLengthExpression(posNode.right, eqNode.right)) ||
(negativeIndexSupported &&
posNode.type === 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: TSESTree.MemberExpression,
): void {
const callNode = getParent(node) as TSESTree.CallExpression;
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 !== AST_NODE_TYPES.Literal &&
argNode.type !== AST_NODE_TYPES.TemplateLiteral &&
argNode.type !== AST_NODE_TYPES.Identifier &&
argNode.type !== AST_NODE_TYPES.MemberExpression &&
argNode.type !== 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,
)}`,
);
},
});
},
};
}
Internal helpers¶
Declared inside another function in this file.
isStringType(node: TSESTree.Expression): boolean¶
Check if a given node is a string.
Parameters:
nodeany: The node to check.
Calls:
services.getTypeAtLocationgetTypeName (from ../util)
Code
isNull(node: TSESTree.Node): node is TSESTree.Literal¶
Check if a given node is a Literal node that is null.
Parameters:
nodeany: The node to check.
Raw JSDoc
Calls:
getStaticValue (from ../util)
Code
isNumber(node: TSESTree.Node, value: number): node is TSESTree.Literal¶
Check if a given node is a Literal node that is a given value.
Parameters:
nodeany: The node to check.valueany: The expected value of theLiteralnode.
Raw JSDoc
Calls:
getStaticValue (from ../util)
Code
isCharacter(node: TSESTree.Node): node is TSESTree.Literal¶
Check if a given node is a Literal node that is a character.
Parameters:
nodeany: The node to check.
Raw JSDoc
Calls:
getStaticValue (from ../util)
Internal Comments:
Code
isEqualityComparison(node: TSESTree.Node): node is TSESTree.BinaryExpression¶
Check if a given node is ==, ===, !=, or !==.
Parameters:
nodeany: The node to check.
Raw JSDoc
Calls:
EQ_OPERATORS.test
Code
isSameTokens(node1: TSESTree.Node, node2: TSESTree.Node): boolean¶
Check if two given nodes are the same meaning.
Parameters:
node1any: A node to compare.node2any: Another node to compare.
Raw JSDoc
Calls:
context.sourceCode.getTokens
Code
function isSameTokens(node1: TSESTree.Node, node2: TSESTree.Node): boolean {
const tokens1 = context.sourceCode.getTokens(node1);
const tokens2 = context.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;
}
isLengthExpression(node: TSESTree.Node, expectedObjectNode: TSESTree.Node): boolean¶
Check if a given node is the expression of the length of a string.
- If
lengthproperty access ofexpectedObjectNode, it'strue. E.g.,fooβfoo.length/"foo"β"foo".length - If
expectedObjectNodeis a string literal,nodecan be a number. E.g.,"foo"β3
Parameters:
nodeany: The node to check.expectedObjectNodeany: The node which is expected as the receiver oflengthproperty.
Raw JSDoc
/**
* 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.
*/
Calls:
getPropertyName (from ../util)isSameTokensgetStaticValue (from ../util)
Code
function isLengthExpression(
node: TSESTree.Node,
expectedObjectNode: TSESTree.Node,
): boolean {
if (node.type === AST_NODE_TYPES.MemberExpression) {
return (
getPropertyName(node, globalScope) === 'length' &&
isSameTokens(node.object, expectedObjectNode)
);
}
const evaluatedLength = getStaticValue(node, globalScope);
const evaluatedString = getStaticValue(expectedObjectNode, globalScope);
return (
evaluatedLength != null &&
evaluatedString != null &&
typeof evaluatedLength.value === 'number' &&
typeof evaluatedString.value === 'string' &&
evaluatedLength.value === evaluatedString.value.length
);
}
isLengthAheadOfEnd(node: TSESTree.Node, substring: TSESTree.Node, parentString: TSESTree.Node): boolean¶
Returns true if node is -substring.length or
parentString.length - substring.length
Raw JSDoc
Calls:
isLengthExpression
Code
function isLengthAheadOfEnd(
node: TSESTree.Node,
substring: TSESTree.Node,
parentString: TSESTree.Node,
): boolean {
return (
(node.type === AST_NODE_TYPES.UnaryExpression &&
node.operator === '-' &&
isLengthExpression(node.argument, substring)) ||
(node.type === AST_NODE_TYPES.BinaryExpression &&
node.operator === '-' &&
isLengthExpression(node.left, parentString) &&
isLengthExpression(node.right, substring))
);
}
isLastIndexExpression(node: TSESTree.Node, expectedObjectNode: TSESTree.Node): boolean¶
Check if a given node is the expression of the last index.
E.g. foo.length - 1
Parameters:
nodeany: The node to check.expectedObjectNodeany: The node which is expected as the receiver oflengthproperty.
Raw JSDoc
Calls:
isLengthExpressionisNumber
Code
getPropertyRange(node: TSESTree.MemberExpression): [number, number]¶
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
Parameters:
nodeany: The member expression node to get.
Raw JSDoc
Calls:
nullThrows (from ../util)context.sourceCode.getTokenAfterNullThrowsReasons.MissingToken
Code
function getPropertyRange(
node: TSESTree.MemberExpression,
): [number, number] {
const dotOrOpenBracket = nullThrows(
context.sourceCode.getTokenAfter(node.object, isNotClosingParenToken),
NullThrowsReasons.MissingToken('closing parenthesis', 'member'),
);
return [dotOrOpenBracket.range[0], node.range[1]];
}
parseRegExpText(pattern: string, unicode: boolean): string | null¶
Parse a given RegExp pattern to that string if it's a static string.
Parameters:
patternany: The RegExp pattern text to parse.unicodeany: Whether the RegExp is unicode.
Raw JSDoc
Calls:
regexpp.parsePatternchars.shiftchars.popchars.everyString.fromCodePointchars.map
Internal Comments:
// Parse it. (x2)
// Drop `^`/`$` assertion. (x2)
// Check if it can determine a unique string.
// To string.
Code
function parseRegExpText(pattern: string, unicode: boolean): string | null {
// Parse it.
const ast = regexpp.parsePattern(pattern, undefined, undefined, {
unicode,
});
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));
}
parseRegExp(node: TSESTree.Node): { isEndsWith: boolean; isStartsWith: boolean; text: string ⦶
Parse a given node if it's a RegExp instance.
Parameters:
nodeany: The node to parse.
Calls:
getStaticValue (from ../util)source.startsWith/[^\\](\\\\)*\$$/.testflags.includesparseRegExpText
Internal Comments:
Code
function parseRegExp(
node: TSESTree.Node,
): { isEndsWith: boolean; isStartsWith: boolean; text: string } | null {
const evaluated = getStaticValue(node, globalScope);
if (evaluated == null || !(evaluated.value instanceof RegExp)) {
return null;
}
const { flags, source } = evaluated.value;
const isStartsWith = source.startsWith('^');
// ends with a $ preceded by an even number of backslashes (or zero)
const isEndsWith = /[^\\](\\\\)*\$$/.test(source);
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 };
}
getLeftNode(init: TSESTree.Expression | TSESTree.PrivateIβ¦): TSESTree.MemberExpression¶
Parameters:
initTSESTree.Expression | TSESTree.PrivateIdentifier
Returns: TSESTree.MemberExpression
Calls:
skipChainExpression (from ../util)
Code
function getLeftNode(
init: TSESTree.Expression | TSESTree.PrivateIdentifier,
): TSESTree.MemberExpression {
const node = skipChainExpression(init);
const leftNode =
node.type === AST_NODE_TYPES.CallExpression ? node.callee : node;
if (leftNode.type !== AST_NODE_TYPES.MemberExpression) {
throw new Error(`Expected a MemberExpression, got ${leftNode.type}`);
}
return leftNode;
}
fixWithRightOperand(fixer: TSESLint.RuleFixer, node: TSESTree.BinaryExpression, kind: 'end' | 'start', isNegative: boolean, isOptional: boolean): IterableIterator<TSESLint.RuleFix>¶
Fix code with using the right operand as the search string.
For example: foo.slice(0, 3) === 'bar' β foo.startsWith('bar')
Parameters:
fixerany: The rule fixer.nodeany: The node which was reported.kindany: The kind of the report.isNegativeany: The flag to fix to negative condition.
Raw JSDoc
/**
* 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.
*/
Calls:
getLeftNodegetPropertyRangefixer.insertTextBeforefixer.replaceTextRange
Internal Comments:
Code
function* fixWithRightOperand(
fixer: TSESLint.RuleFixer,
node: TSESTree.BinaryExpression,
kind: 'end' | 'start',
isNegative: boolean,
isOptional: boolean,
): IterableIterator<TSESLint.RuleFix> {
// 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]], ')');
}
fixWithArgument(β¦): IterableIterator<TSESLint.RuleFix>¶
Fix code with using the first argument as the search string.
For example: foo.indexOf('bar') === 0 β foo.startsWith('bar')
Parameters:
fixerany: The rule fixer.nodeany: The node which was reported.kindany: The kind of the report.negativeany: The flag to fix to negative condition.
Raw JSDoc
/**
* 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.
*/
Calls:
fixer.insertTextBeforefixer.replaceTextRangegetPropertyRangefixer.removeRange
Code
function* fixWithArgument(
fixer: TSESLint.RuleFixer,
node: TSESTree.BinaryExpression,
callNode: TSESTree.CallExpression,
calleeNode: TSESTree.MemberExpression,
kind: 'end' | 'start',
negative: boolean,
isOptional: boolean,
): IterableIterator<TSESLint.RuleFix> {
if (negative) {
yield fixer.insertTextBefore(node, '!');
}
yield fixer.replaceTextRange(
getPropertyRange(calleeNode),
`${isOptional ? '?.' : '.'}${kind}sWith`,
);
yield fixer.removeRange([callNode.range[1], node.range[1]]);
}
getParent(node: NodeWithParent): TSESTree.Node¶
Parameters:
nodeNodeWithParent
Returns: TSESTree.Node
Code
Type Aliases¶
AllowedSingleElementEquality¶
Options¶
MessageIds¶
Generated by Syntax Scribe