Skip to content

⬅️ 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

export default createRule<Options, MessageIds>({ ... })
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:

  • context any
  • [{ allowSingleElementEquality }] any

Returns: { [x: string]: (node: TSESTree.MemberExpression) => void; 'CallExpression > MemberExpression.callee[property.name="test"][computed=false]'(node: TSESTree.MemberExpression): void; }

Calls:

  • context.sourceCode.getScope
  • getParserServices (from ../util)
  • services.program.getTypeChecker
  • services.getTypeAtLocation
  • getTypeName (from ../util)
  • getStaticValue (from ../util)
  • EQ_OPERATORS.test
  • context.sourceCode.getTokens
  • getPropertyName (from ../util)
  • isSameTokens
  • isLengthExpression
  • isNumber
  • nullThrows (from ../util)
  • context.sourceCode.getTokenAfter
  • NullThrowsReasons.MissingToken
  • regexpp.parsePattern
  • chars.shift
  • chars.pop
  • chars.every
  • String.fromCodePoint
  • chars.map
  • source.startsWith
  • /[^\\](\\\\)*\$$/.test
  • flags.includes
  • parseRegExpText
  • skipChainExpression (from ../util)
  • getLeftNode
  • getPropertyRange
  • fixer.insertTextBefore
  • fixer.replaceTextRange
  • fixer.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]', ].join
  • getParent
  • isEqualityComparison
  • isStringType
  • isLastIndexExpression
  • context.report
  • isCharacter
  • fixWithRightOperand
  • eqNode.operator.startsWith
  • [ 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="indexOf"][computed=false]', 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="indexOf"][computed=false]', ].join
  • fixWithArgument
  • parentNode.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]', ].join
  • isNull
  • parseRegExp
  • fixer.replaceText
  • JSON.stringify
  • [ 'BinaryExpression > CallExpression.left > MemberExpression', 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression', ].join
  • isStaticMemberAccessOfValue (from ../util)
  • isLengthAheadOfEnd
  • fixer.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:

  • node any: The node to check.
Raw JSDoc
/**
     * Check if a given node is a string.
     * @param node The node to check.
     */

Calls:

  • services.getTypeAtLocation
  • getTypeName (from ../util)
Code
function isStringType(node: TSESTree.Expression): boolean {
      const objectType = services.getTypeAtLocation(node);
      return getTypeName(checker, objectType) === 'string';
    }

isNull(node: TSESTree.Node): node is TSESTree.Literal

Check if a given node is a Literal node that is null.

Parameters:

  • node any: The node to check.
Raw JSDoc
/**
     * Check if a given node is a `Literal` node that is null.
     * @param node The node to check.
     */

Calls:

  • getStaticValue (from ../util)
Code
function isNull(node: TSESTree.Node): node is TSESTree.Literal {
      const evaluated = getStaticValue(node, globalScope);
      return evaluated != null && evaluated.value == null;
    }

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:

  • node any: The node to check.
  • value any: The expected value of the Literal node.
Raw JSDoc
/**
     * 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.
     */

Calls:

  • getStaticValue (from ../util)
Code
function isNumber(
      node: TSESTree.Node,
      value: number,
    ): node is TSESTree.Literal {
      const evaluated = getStaticValue(node, globalScope);
      return evaluated?.value === value;
    }

isCharacter(node: TSESTree.Node): node is TSESTree.Literal

Check if a given node is a Literal node that is a character.

Parameters:

  • node any: The node to check.
Raw JSDoc
/**
     * Check if a given node is a `Literal` node that is a character.
     * @param node The node to check.
     */

Calls:

  • getStaticValue (from ../util)

Internal Comments:

// checks if the string is a character long (x4)

Code
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
      );
    }

isEqualityComparison(node: TSESTree.Node): node is TSESTree.BinaryExpression

Check if a given node is ==, ===, !=, or !==.

Parameters:

  • node any: The node to check.
Raw JSDoc
/**
     * Check if a given node is `==`, `===`, `!=`, or `!==`.
     * @param node The node to check.
     */

Calls:

  • EQ_OPERATORS.test
Code
function isEqualityComparison(
      node: TSESTree.Node,
    ): node is TSESTree.BinaryExpression {
      return (
        node.type === AST_NODE_TYPES.BinaryExpression &&
        EQ_OPERATORS.test(node.operator)
      );
    }

isSameTokens(node1: TSESTree.Node, node2: TSESTree.Node): boolean

Check if two given nodes are the same meaning.

Parameters:

  • node1 any: A node to compare.
  • node2 any: Another node to compare.
Raw JSDoc
/**
     * Check if two given nodes are the same meaning.
     * @param node1 A node to compare.
     * @param node2 Another node to compare.
     */

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 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

Parameters:

  • node any: The node to check.
  • expectedObjectNode any: The node which is expected as the receiver of length property.
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)
  • isSameTokens
  • getStaticValue (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
/**
     * Returns true if `node` is `-substring.length` or
     * `parentString.length - substring.length`
     */

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:

  • node any: The node to check.
  • expectedObjectNode any: The node which is expected as the receiver of length property.
Raw JSDoc
/**
     * 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.
     */

Calls:

  • isLengthExpression
  • isNumber
Code
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)
      );
    }

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:

  • node any: The member expression node to get.
Raw JSDoc
/**
     * 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.
     */

Calls:

  • nullThrows (from ../util)
  • context.sourceCode.getTokenAfter
  • NullThrowsReasons.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:

  • pattern any: The RegExp pattern text to parse.
  • unicode any: Whether the RegExp is unicode.
Raw JSDoc
/**
     * 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.
     */

Calls:

  • regexpp.parsePattern
  • chars.shift
  • chars.pop
  • chars.every
  • String.fromCodePoint
  • chars.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:

  • node any: The node to parse.
Raw JSDoc
/**
     * Parse a given node if it's a `RegExp` instance.
     * @param node The node to parse.
     */

Calls:

  • getStaticValue (from ../util)
  • source.startsWith
  • /[^\\](\\\\)*\$$/.test
  • flags.includes
  • parseRegExpText

Internal Comments:

// ends with a $ preceded by an even number of backslashes (or zero) (x2)

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:

  • init TSESTree.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:

  • fixer any: The rule fixer.
  • node any: The node which was reported.
  • kind any: The kind of the report.
  • isNegative any: 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:

  • getLeftNode
  • getPropertyRange
  • fixer.insertTextBefore
  • fixer.replaceTextRange

Internal Comments:

// left is CallExpression or MemberExpression. (x2)

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:

  • fixer any: The rule fixer.
  • node any: The node which was reported.
  • kind any: The kind of the report.
  • negative any: 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.insertTextBefore
  • fixer.replaceTextRange
  • getPropertyRange
  • fixer.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:

  • node NodeWithParent

Returns: TSESTree.Node

Code
function getParent(node: NodeWithParent): TSESTree.Node {
      return node.parent.type === AST_NODE_TYPES.ChainExpression
        ? node.parent.parent
        : node.parent;
    }

Type Aliases

AllowedSingleElementEquality

type AllowedSingleElementEquality = 'always' | 'never';

Options

type Options = [
  {
    allowSingleElementEquality?: AllowedSingleElementEquality;
  },
];

MessageIds

type MessageIds = 'preferEndsWith' | 'preferStartsWith';

Generated by Syntax Scribe