From 5592a2ca04c14c934cf4c204046572ee26bb067f Mon Sep 17 00:00:00 2001 From: Toru Nagashima Date: Thu, 11 Apr 2019 10:18:44 +0900 Subject: [PATCH] feat(eslint-plugin): add prefer-string-starts-ends-with rule (#289) Fixes #285 --- .../rules/prefer-string-starts-ends-with.md | 54 + .../rules/prefer-string-starts-ends-with.ts | 645 ++++++++++ packages/eslint-plugin/tests/RuleTester.ts | 2 +- .../prefer-string-starts-ends-with.test.ts | 1046 +++++++++++++++++ packages/eslint-plugin/typings/ts-eslint.d.ts | 3 +- 5 files changed, 1748 insertions(+), 2 deletions(-) create mode 100644 packages/eslint-plugin/docs/rules/prefer-string-starts-ends-with.md create mode 100644 packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts create mode 100644 packages/eslint-plugin/tests/rules/prefer-string-starts-ends-with.test.ts diff --git a/packages/eslint-plugin/docs/rules/prefer-string-starts-ends-with.md b/packages/eslint-plugin/docs/rules/prefer-string-starts-ends-with.md new file mode 100644 index 00000000000..07ad4e1148f --- /dev/null +++ b/packages/eslint-plugin/docs/rules/prefer-string-starts-ends-with.md @@ -0,0 +1,54 @@ +# Enforce the use of `String#startsWith` and `String#endsWith` instead of other equivalent methods of checking substrings (prefer-string-starts-ends-with) + +There are multiple ways to verify if a string starts or ends with a specific string, such as `foo.indexOf('bar') === 0`. + +Since ES2015 has added `String#startsWith` and `String#endsWith`, this rule reports other ways to be consistent. + +## Rule Details + +This rule is aimed at enforcing a consistent way to check whether a string starts or ends with a specific string. + +Examples of **incorrect** code for this rule: + +```ts +let foo: string; + +// starts with +foo[0] === 'b'; +foo.charAt(0) === 'b'; +foo.indexOf('bar') === 0; +foo.slice(0, 3) === 'bar'; +foo.substring(0, 3) === 'bar'; +foo.match(/^bar/) != null; +/^bar/.test(foo); + +// ends with +foo[foo.length - 1] === 'b'; +foo.charAt(foo.length - 1) === 'b'; +foo.lastIndexOf('bar') === foo.length - 3; +foo.slice(-3) === 'bar'; +foo.substring(foo.length - 3) === 'bar'; +foo.match(/bar$/) != null; +/bar$/.test(foo); +``` + +Examples of **correct** code for this rule: + +```ts +foo.startsWith('bar'); +foo.endsWith('bar'); +``` + +## Options + +There are no options. + +```JSON +{ + "@typescript-eslint/prefer-string-starts-ends-with": "error" +} +``` + +## When Not To Use It + +If you don't mind that style, you can turn this rule off safely. diff --git a/packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts b/packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts new file mode 100644 index 00000000000..6b3079cdfdf --- /dev/null +++ b/packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts @@ -0,0 +1,645 @@ +import { TSESTree } from '@typescript-eslint/typescript-estree'; +import { + isNotClosingParenToken, + getPropertyName, + getStaticValue, +} from 'eslint-utils'; +import { RegExpParser, AST as RegExpAST } from 'regexpp'; +import { RuleFixer, RuleFix } from 'ts-eslint'; +import ts from 'typescript'; +import { createRule, getParserServices } from '../util'; + +const EQ_OPERATORS = /^[=!]=/; +const regexpp = new RegExpParser(); + +export default 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, + }, + 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 = getParserServices(context); + const types = service.program.getTypeChecker(); + + /** + * Get the type name of a given type. + * @param type The type to get. + */ + function getTypeName(type: ts.Type): string { + // It handles `string` and string literal types as string. + if ((type.flags & ts.TypeFlags.StringLike) !== 0) { + return 'string'; + } + + // If the type is a type parameter which extends primitive string types, + // but it was not recognized as a string like. So check the constraint + // type of the type parameter. + if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) { + // `type.getConstraint()` method doesn't return the constraint type of + // the type parameter for some reason. So this gets the constraint type + // via AST. + const node = type.symbol.declarations[0] as ts.TypeParameterDeclaration; + if (node.constraint != null) { + return getTypeName(types.getTypeFromTypeNode(node.constraint)); + } + } + + // If the type is a union and all types in the union are string like, + // return `string`. For example: + // - `"a" | "b"` is string. + // - `string | string[]` is not string. + if ( + type.isUnion() && + type.types.map(getTypeName).every(t => t === 'string') + ) { + return 'string'; + } + + // If the type is an intersection and a type in the intersection is string + // like, return `string`. For example: `string & {__htmlEscaped: void}` + if ( + type.isIntersection() && + type.types.map(getTypeName).some(t => t === 'string') + ) { + return 'string'; + } + + return types.typeToString(type); + } + + /** + * Check if a given node is a string. + * @param node The node to check. + */ + function isStringType(node: TSESTree.Node): boolean { + const objectType = types.getTypeAtLocation( + service.esTreeNodeToTSNodeMap.get(node), + ); + const typeName = getTypeName(objectType); + + return typeName === '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 != 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: TSESTree.Node): node is TSESTree.Literal { + const evaluated = getStaticValue(node, globalScope); + return ( + evaluated != null && + typeof evaluated.value === 'string' && + 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 === '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 = 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: TSESTree.Node, + expectedObjectNode: TSESTree.Node, + ): boolean { + if (node.type === '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 + ); + } + + /** + * 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 === '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 = sourceCode.getTokenAfter( + node.object, + 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: string, uFlag: boolean): string | null { + // 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 as RegExpAST.Character).value), + ); + } + + /** + * Parse a given node if it's a `RegExp` instance. + * @param node The node to parse. + */ + function parseRegExp( + node: TSESTree.Node, + ): { isStartsWith: boolean; isEndsWith: boolean; text: string } | null { + const evaluated = 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 }; + } + + /** + * 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 negative The flag to fix to negative condition. + */ + function* fixWithRightOperand( + fixer: RuleFixer, + node: TSESTree.BinaryExpression, + kind: 'start' | 'end', + negative: boolean, + ): IterableIterator { + // left is CallExpression or MemberExpression. + const leftNode = (node.left.type === 'CallExpression' + ? node.left.callee + : node.left) as TSESTree.MemberExpression; + const propertyRange = getPropertyRange(leftNode); + + if (negative) { + yield fixer.insertTextBefore(node, '!'); + } + yield fixer.replaceTextRange( + [propertyRange[0], node.right.range[0]], + `.${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: RuleFixer, + node: TSESTree.BinaryExpression, + kind: 'start' | 'end', + negative: boolean, + ): IterableIterator { + const callNode = node.left as TSESTree.CallExpression; + const calleeNode = callNode.callee as TSESTree.MemberExpression; + + if (negative) { + yield fixer.insertTextBefore(node, '!'); + } + yield fixer.replaceTextRange( + getPropertyRange(calleeNode), + `.${kind}sWith`, + ); + yield fixer.removeRange([callNode.range[1], node.range[1]]); + } + + return { + // foo[0] === "a" + // foo.charAt(0) === "a" + // foo[foo.length - 1] === "a" + // foo.charAt(foo.length - 1) === "a" + [String([ + 'BinaryExpression > MemberExpression.left[computed=true]', + 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="charAt"][computed=false]', + ])](node: TSESTree.MemberExpression): void { + let parentNode = node.parent!; + let indexNode: TSESTree.Node | null = null; + if (parentNode.type === 'CallExpression') { + if (parentNode.arguments.length === 1) { + indexNode = parentNode.arguments[0]; + } + parentNode = parentNode.parent!; + } 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('!'), + ); + }, + }); + }, + + // foo.indexOf('bar') === 0 + 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="indexOf"][computed=false]'( + node: TSESTree.MemberExpression, + ): void { + const callNode = node.parent! as TSESTree.CallExpression; + const parentNode = callNode.parent!; + + if ( + callNode.arguments.length !== 1 || + !isEqualityComparison(parentNode) || + parentNode.left !== callNode || + !isNumber(parentNode.right, 0) || + !isStringType(node.object) + ) { + return; + } + + context.report({ + node: parentNode, + messageId: 'preferStartsWith', + fix(fixer) { + return fixWithArgument( + fixer, + parentNode, + 'start', + parentNode.operator.startsWith('!'), + ); + }, + }); + }, + + // foo.lastIndexOf('bar') === foo.length - 3 + // foo.lastIndexOf(bar) === foo.length - bar.length + 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="lastIndexOf"][computed=false]'( + node: TSESTree.MemberExpression, + ): void { + const callNode = node.parent! as TSESTree.CallExpression; + const parentNode = callNode.parent!; + + if ( + callNode.arguments.length !== 1 || + !isEqualityComparison(parentNode) || + parentNode.left !== callNode || + parentNode.right.type !== '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, + 'end', + parentNode.operator.startsWith('!'), + ); + }, + }); + }, + + // foo.match(/^bar/) === null + // foo.match(/bar$/) === null + 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="match"][computed=false]'( + node: TSESTree.MemberExpression, + ): void { + const callNode = node.parent as TSESTree.CallExpression; + const parentNode = callNode.parent as TSESTree.BinaryExpression; + 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), + `.${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' + [String([ + 'CallExpression > MemberExpression.callee[property.name=slice][computed=false]', + 'CallExpression > MemberExpression.callee[property.name=substring][computed=false]', + ])](node: TSESTree.MemberExpression): void { + const callNode = node.parent! as TSESTree.CallExpression; + const parentNode = callNode.parent!; + if ( + !isEqualityComparison(parentNode) || + parentNode.left !== callNode || + !isStringType(node.object) + ) { + return; + } + + const isEndsWith = + callNode.arguments.length === 1 || + (callNode.arguments.length === 2 && + isLengthExpression(callNode.arguments[1], 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 as any).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 !== 'Literal' || + typeof eqNode.right.value !== 'string') + ) { + return null; + } + if (isStartsWith) { + if (!isLengthExpression(callNode.arguments[1], eqNode.right)) { + return null; + } + } else { + const posNode = callNode.arguments[0]; + const posNodeIsAbsolutelyValid = + (posNode.type === 'BinaryExpression' && + posNode.operator === '-' && + isLengthExpression(posNode.left, node.object) && + isLengthExpression(posNode.right, eqNode.right)) || + (negativeIndexSupported && + posNode.type === 'UnaryExpression' && + posNode.operator === '-' && + isLengthExpression(posNode.argument, eqNode.right)); + if (!posNodeIsAbsolutelyValid) { + return null; + } + } + + return fixWithRightOperand( + fixer, + parentNode, + isStartsWith ? 'start' : 'end', + parentNode.operator.startsWith('!'), + ); + }, + }); + }, + + // /^bar/.test(foo) + // /bar$/.test(foo) + 'CallExpression > MemberExpression.callee[property.name="test"][computed=false]'( + node: TSESTree.MemberExpression, + ): void { + const callNode = node.parent 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 !== 'Literal' && + argNode.type !== 'TemplateLiteral' && + argNode.type !== 'Identifier' && + argNode.type !== 'MemberExpression' && + argNode.type !== 'CallExpression'; + + yield fixer.removeRange([callNode.range[0], argNode.range[0]]); + if (needsParen) { + yield fixer.insertTextBefore(argNode, '('); + yield fixer.insertTextAfter(argNode, ')'); + } + yield fixer.insertTextAfter( + argNode, + `.${methodName}(${JSON.stringify(text)}`, + ); + }, + }); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/RuleTester.ts b/packages/eslint-plugin/tests/RuleTester.ts index d485611be44..c51fa532a82 100644 --- a/packages/eslint-plugin/tests/RuleTester.ts +++ b/packages/eslint-plugin/tests/RuleTester.ts @@ -22,7 +22,7 @@ interface InvalidTestCase< TOptions extends Readonly > extends ValidTestCase { errors: TestCaseError[]; - output?: string; + output?: string | null; } interface TestCaseError { diff --git a/packages/eslint-plugin/tests/rules/prefer-string-starts-ends-with.test.ts b/packages/eslint-plugin/tests/rules/prefer-string-starts-ends-with.test.ts new file mode 100644 index 00000000000..cc1e0063bd2 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/prefer-string-starts-ends-with.test.ts @@ -0,0 +1,1046 @@ +import path from 'path'; +import rule from '../../src/rules/prefer-string-starts-ends-with'; +import { RuleTester } from '../RuleTester'; + +const rootPath = path.join(process.cwd(), 'tests/fixtures/'); + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +ruleTester.run('prefer-string-starts-ends-with', rule, { + valid: [ + ` + function f(s: string[]) { + s[0] === "a" + } + `, + ` + function f(s: string) { + s[0] + "a" + } + `, + ` + function f(s: string) { + s[1] === "a" + } + `, + ` + function f(s: string | string[]) { + s[0] === "a" + } + `, + ` + function f(s: any) { + s[0] === "a" + } + `, + ` + function f(s: T) { + s[0] === "a" + } + `, + ` + function f(s: string[]) { + s[s.length - 1] === "a" + } + `, + ` + function f(s: string) { + s[s.length - 2] === "a" + } + `, + ` + function f(s: string[]) { + s.charAt(0) === "a" + } + `, + ` + function f(s: string) { + s.charAt(0) + "a" + } + `, + ` + function f(s: string) { + s.charAt(1) === "a" + } + `, + ` + function f(s: string) { + s.charAt() === "a" + } + `, + ` + function f(s: string[]) { + s.charAt(s.length - 1) === "a" + } + `, + ` + function f(a: string, b: string, c: string) { + (a + b).charAt((a + c).length - 1) === "a" + } + `, + ` + function f(a: string, b: string, c: string) { + (a + b).charAt(c.length - 1) === "a" + } + `, + ` + function f(s: string[]) { + s.indexOf(needle) === 0 + } + `, + ` + function f(s: string | string[]) { + s.indexOf(needle) === 0 + } + `, + ` + function f(s: string) { + s.indexOf(needle) === s.length - needle.length + } + `, + ` + function f(s: string[]) { + s.lastIndexOf(needle) === s.length - needle.length + } + `, + ` + function f(s: string) { + s.lastIndexOf(needle) === 0 + } + `, + ` + function f(s: string) { + s.match(/^foo/) + } + `, + ` + function f(s: string) { + s.match(/foo$/) + } + `, + ` + function f(s: string) { + s.match(/^foo/) + 1 + } + `, + ` + function f(s: string) { + s.match(/foo$/) + 1 + } + `, + ` + function f(s: { match(x: any): boolean }) { + s.match(/^foo/) !== null + } + `, + ` + function f(s: { match(x: any): boolean }) { + s.match(/foo$/) !== null + } + `, + ` + function f(s: string) { + s.match(/foo/) !== null + } + `, + ` + function f(s: string) { + s.match(/^foo$/) !== null + } + `, + ` + function f(s: string) { + s.match(/^foo./) !== null + } + `, + ` + function f(s: string) { + s.match(/^foo|bar/) !== null + } + `, + ` + function f(s: string) { + s.match(new RegExp("")) !== null + } + `, + ` + function f(s: string) { + s.match(pattern) !== null // cannot check '^'/'$' + } + `, + ` + function f(s: string) { + s.match(new RegExp("^/!{[", "u")) !== null // has syntax error + } + `, + ` + function f(s: string) { + s.match() !== null + } + `, + ` + function f(s: string) { + s.match(777) !== null + } + `, + ` + function f(s: string[]) { + s.slice(0, needle.length) === needle + } + `, + ` + function f(s: string[]) { + s.slice(-needle.length) === needle + } + `, + ` + function f(s: string) { + s.slice(1, 4) === "bar" + } + `, + ` + function f(s: string) { + s.slice(-4, -1) === "bar" + } + `, + ` + function f(s: string) { + pattern.test(s) + } + `, + ` + function f(s: string) { + /^bar/.test() + } + `, + ` + function f(x: { test(): void }, s: string) { + x.test(s) + } + `, + ], + invalid: [ + // String indexing. + { + code: ` + function f(s: string) { + s[0] === "a" + } + `, + output: ` + function f(s: string) { + s.startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s[0] !== "a" + } + `, + output: ` + function f(s: string) { + !s.startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s[0] == "a" + } + `, + output: ` + function f(s: string) { + s.startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s[0] != "a" + } + `, + output: ` + function f(s: string) { + !s.startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s[0] === "あ" + } + `, + output: ` + function f(s: string) { + s.startsWith("あ") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s[0] === "👍" // the length is 2. + } + `, + output: null, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string, t: string) { + s[0] === t // the length of t is unknown. + } + `, + output: null, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s[s.length - 1] === "a" + } + `, + output: ` + function f(s: string) { + s.endsWith("a") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + (s)[0] === ("a") + } + `, + output: ` + function f(s: string) { + (s).startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + + // String#charAt + { + code: ` + function f(s: string) { + s.charAt(0) === "a" + } + `, + output: ` + function f(s: string) { + s.startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.charAt(0) !== "a" + } + `, + output: ` + function f(s: string) { + !s.startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.charAt(0) == "a" + } + `, + output: ` + function f(s: string) { + s.startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.charAt(0) != "a" + } + `, + output: ` + function f(s: string) { + !s.startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.charAt(0) === "あ" + } + `, + output: ` + function f(s: string) { + s.startsWith("あ") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.charAt(0) === "👍" // the length is 2. + } + `, + output: null, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string, t: string) { + s.charAt(0) === t // the length of t is unknown. + } + `, + output: null, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.charAt(s.length - 1) === "a" + } + `, + output: ` + function f(s: string) { + s.endsWith("a") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + (s).charAt(0) === "a" + } + `, + output: ` + function f(s: string) { + (s).startsWith("a") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + + // String#indexOf + { + code: ` + function f(s: string) { + s.indexOf(needle) === 0 + } + `, + output: ` + function f(s: string) { + s.startsWith(needle) + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.indexOf(needle) !== 0 + } + `, + output: ` + function f(s: string) { + !s.startsWith(needle) + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.indexOf(needle) == 0 + } + `, + output: ` + function f(s: string) { + s.startsWith(needle) + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.indexOf(needle) != 0 + } + `, + output: ` + function f(s: string) { + !s.startsWith(needle) + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + + // String#lastIndexOf + { + code: ` + function f(s: string) { + s.lastIndexOf("bar") === s.length - 3 + } + `, + output: ` + function f(s: string) { + s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.lastIndexOf("bar") !== s.length - 3 + } + `, + output: ` + function f(s: string) { + !s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.lastIndexOf("bar") == s.length - 3 + } + `, + output: ` + function f(s: string) { + s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.lastIndexOf("bar") != s.length - 3 + } + `, + output: ` + function f(s: string) { + !s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.lastIndexOf("bar") === s.length - "bar".length + } + `, + output: ` + function f(s: string) { + s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.lastIndexOf(needle) === s.length - needle.length + } + `, + output: ` + function f(s: string) { + s.endsWith(needle) + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + + // String#match + { + code: ` + function f(s: string) { + s.match(/^bar/) !== null + } + `, + output: ` + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.match(/^bar/) != null + } + `, + output: ` + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.match(/bar$/) !== null + } + `, + output: ` + function f(s: string) { + s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.match(/bar$/) != null + } + `, + output: ` + function f(s: string) { + s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.match(/^bar/) === null + } + `, + output: ` + function f(s: string) { + !s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.match(/^bar/) == null + } + `, + output: ` + function f(s: string) { + !s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.match(/bar$/) === null + } + `, + output: ` + function f(s: string) { + !s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.match(/bar$/) == null + } + `, + output: ` + function f(s: string) { + !s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + const pattern = /^bar/ + function f(s: string) { + s.match(pattern) != null + } + `, + output: ` + const pattern = /^bar/ + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + const pattern = new RegExp("^bar") + function f(s: string) { + s.match(pattern) != null + } + `, + output: ` + const pattern = new RegExp("^bar") + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + const pattern = /^"quoted"/ + function f(s: string) { + s.match(pattern) != null + } + `, + output: ` + const pattern = /^"quoted"/ + function f(s: string) { + s.startsWith("\\"quoted\\"") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + + // String#slice + { + code: ` + function f(s: string) { + s.slice(0, 3) === "bar" + } + `, + output: ` + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(0, 3) !== "bar" + } + `, + output: ` + function f(s: string) { + !s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(0, 3) == "bar" + } + `, + output: ` + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(0, 3) != "bar" + } + `, + output: ` + function f(s: string) { + !s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(0, needle.length) === needle + } + `, + output: ` + function f(s: string) { + s.startsWith(needle) + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(0, length) === needle // the 'length' can be different to 'needle.length' + } + `, + output: null, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(0, needle.length) == needle // hating implicit type conversion + } + `, + output: null, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(-3) === "bar" + } + `, + output: ` + function f(s: string) { + s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(-3) !== "bar" + } + `, + output: ` + function f(s: string) { + !s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(-needle.length) === needle + } + `, + output: ` + function f(s: string) { + s.endsWith(needle) + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(s.length - needle.length) === needle + } + `, + output: ` + function f(s: string) { + s.endsWith(needle) + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.slice(startIndex) === needle // 'startIndex' can be different + } + `, + output: null, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.substring(0, 3) === "bar" + } + `, + output: ` + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + s.substring(-3) === "bar" // the code is probably mistake. + } + `, + output: null, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + function f(s: string) { + s.substring(s.length - 3, s.length) === "bar" + } + `, + output: ` + function f(s: string) { + s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + + // RegExp#test + { + code: ` + function f(s: string) { + /^bar/.test(s) + } + `, + output: ` + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + /bar$/.test(s) + } + `, + output: ` + function f(s: string) { + s.endsWith("bar") + } + `, + errors: [{ messageId: 'preferEndsWith' }], + }, + { + code: ` + const pattern = /^bar/ + function f(s: string) { + pattern.test(s) + } + `, + output: ` + const pattern = /^bar/ + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + const pattern = new RegExp("^bar") + function f(s: string) { + pattern.test(s) + } + `, + output: ` + const pattern = new RegExp("^bar") + function f(s: string) { + s.startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + const pattern = /^"quoted"/ + function f(s: string) { + pattern.test(s) + } + `, + output: ` + const pattern = /^"quoted"/ + function f(s: string) { + s.startsWith("\\"quoted\\"") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: string) { + /^bar/.test(a + b) + } + `, + output: ` + function f(s: string) { + (a + b).startsWith("bar") + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + + // Test for variation of string types. + { + code: ` + function f(s: "a" | "b") { + s.indexOf(needle) === 0 + } + `, + output: ` + function f(s: "a" | "b") { + s.startsWith(needle) + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + function f(s: T) { + s.indexOf(needle) === 0 + } + `, + output: ` + function f(s: T) { + s.startsWith(needle) + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + { + code: ` + type SafeString = string & {__HTML_ESCAPED__: void} + function f(s: SafeString) { + s.indexOf(needle) === 0 + } + `, + output: ` + type SafeString = string & {__HTML_ESCAPED__: void} + function f(s: SafeString) { + s.startsWith(needle) + } + `, + errors: [{ messageId: 'preferStartsWith' }], + }, + ], +}); diff --git a/packages/eslint-plugin/typings/ts-eslint.d.ts b/packages/eslint-plugin/typings/ts-eslint.d.ts index d6bd1eef8a7..34705d73e34 100644 --- a/packages/eslint-plugin/typings/ts-eslint.d.ts +++ b/packages/eslint-plugin/typings/ts-eslint.d.ts @@ -299,7 +299,7 @@ declare module 'ts-eslint' { type ReportFixFunction = ( fixer: RuleFixer, - ) => null | RuleFix | Iterable; + ) => null | RuleFix | RuleFix[] | IterableIterator; interface ReportDescriptor { /** @@ -690,6 +690,7 @@ declare module 'ts-eslint' { ReportFixFunction, RuleContext, RuleFix, + RuleFixer, RuleFunction, RuleListener, RuleMetaData,