diff --git a/docs/rules/prefer-starts-ends-with.md b/docs/rules/prefer-starts-ends-with.md index 54ad3f0d07..3966915737 100644 --- a/docs/rules/prefer-starts-ends-with.md +++ b/docs/rules/prefer-starts-ends-with.md @@ -2,6 +2,7 @@ There are several ways of checking whether a string starts or ends with a certain string, such as `string.indexOf('foo') === 0` or using a regex with `/^foo/` or `/foo$/`. ES2015 introduced simpler alternatives named [`String#startsWith()`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith) and [`String#endsWith()`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith). This rule enforces the use of those whenever possible. +This rule is partly fixable. ## Fail @@ -10,7 +11,6 @@ There are several ways of checking whether a string starts or ends with a certai /bar$/.test(foo); ``` - ## Pass ```js diff --git a/readme.md b/readme.md index 511be35d99..c0a06c6583 100644 --- a/readme.md +++ b/readme.md @@ -135,7 +135,7 @@ Configure it in `package.json`. - [prefer-replace-all](docs/rules/prefer-replace-all.md) - Prefer `String#replaceAll()` over regex searches with the global flag. *(fixable)* - [prefer-set-has](docs/rules/prefer-set-has.md) - Prefer `Set#has()` over `Array#includes()` when checking for existence or non-existence. *(fixable)* - [prefer-spread](docs/rules/prefer-spread.md) - Prefer the spread operator over `Array.from()`. *(fixable)* -- [prefer-starts-ends-with](docs/rules/prefer-starts-ends-with.md) - Prefer `String#startsWith()` & `String#endsWith()` over more complex alternatives. +- [prefer-starts-ends-with](docs/rules/prefer-starts-ends-with.md) - Prefer `String#startsWith()` & `String#endsWith()` over more complex alternatives. *(partly fixable)* - [prefer-string-slice](docs/rules/prefer-string-slice.md) - Prefer `String#slice()` over `String#substr()` and `String#substring()`. *(partly fixable)* - [prefer-text-content](docs/rules/prefer-text-content.md) - Prefer `.textContent` over `.innerText`. *(fixable)* - [prefer-trim-start-end](docs/rules/prefer-trim-start-end.md) - Prefer `String#trimStart()` / `String#trimEnd()` over `String#trimLeft()` / `String#trimRight()`. *(fixable)* diff --git a/rules/prefer-starts-ends-with.js b/rules/prefer-starts-ends-with.js index 5d849dc58d..e8ae415d54 100644 --- a/rules/prefer-starts-ends-with.js +++ b/rules/prefer-starts-ends-with.js @@ -1,5 +1,8 @@ 'use strict'; +const {isParenthesized} = require('eslint-utils'); const getDocumentationUrl = require('./utils/get-documentation-url'); +const methodSelector = require('./utils/method-selector'); +const quoteString = require('./utils/quote-string'); const MESSAGE_STARTS_WITH = 'prefer-starts-with'; const MESSAGE_ENDS_WITH = 'prefer-ends-with'; @@ -11,54 +14,96 @@ const isSimpleString = string => doesNotContain( ['^', '$', '+', '[', '{', '(', '\\', '.', '?', '*'] ); +const regexTestSelector = [ + methodSelector({name: 'test', length: 1}), + '[callee.object.regex]' +].join(''); + +const stringMatchSelector = [ + methodSelector({name: 'match', length: 1}), + '[arguments.0.regex]' +].join(''); + +const checkRegex = ({pattern, flags}) => { + if (flags.includes('i')) { + return; + } + + if (pattern.startsWith('^')) { + const string = pattern.slice(1); + + if (isSimpleString(string)) { + return { + messageId: MESSAGE_STARTS_WITH, + string + }; + } + } + + if (pattern.endsWith('$')) { + const string = pattern.slice(0, -1); + + if (isSimpleString(string)) { + return { + messageId: MESSAGE_ENDS_WITH, + string + }; + } + } +}; + const create = context => { - return { - CallExpression(node) { - const {callee} = node; - const {property} = callee; + const sourceCode = context.getSourceCode(); - if (!(property && callee.type === 'MemberExpression')) { + return { + [regexTestSelector](node) { + const regexNode = node.callee.object; + const {regex} = regexNode; + const result = checkRegex(regex); + if (!result) { return; } - const arguments_ = node.arguments; - - let regex; - if (property.name === 'test' && callee.object.regex) { - ({regex} = callee.object); - } else if ( - property.name === 'match' && - arguments_ && - arguments_[0] && - arguments_[0].regex - ) { - ({regex} = arguments_[0]); - } else { - return; - } + context.report({ + node, + messageId: result.messageId, + fix: fixer => { + const method = result.messageId === MESSAGE_STARTS_WITH ? 'startsWith' : 'endsWith'; + const [target] = node.arguments; + let targetString = sourceCode.getText(target); + + if ( + // If regex is parenthesized, we can use it, so we don't need add again + !isParenthesized(regexNode, sourceCode) && + (isParenthesized(target, sourceCode) || target.type === 'AwaitExpression') + ) { + targetString = `(${targetString})`; + } - if (regex.flags && regex.flags.includes('i')) { + // The regex literal always starts with `/` or `(`, so we don't need check ASI + + return [ + // Replace regex with string + fixer.replaceText(regexNode, targetString), + // `.test` => `.startsWith` / `.endsWith` + fixer.replaceText(node.callee.property, method), + // Replace argument with result.string + fixer.replaceText(target, quoteString(result.string)) + ]; + } + }); + }, + [stringMatchSelector](node) { + const {regex} = node.arguments[0]; + const result = checkRegex(regex); + if (!result) { return; } - const {pattern} = regex; - if ( - pattern.startsWith('^') && - isSimpleString(pattern.slice(1)) - ) { - context.report({ - node, - messageId: MESSAGE_STARTS_WITH - }); - } else if ( - pattern.endsWith('$') && - isSimpleString(pattern.slice(0, -1)) - ) { - context.report({ - node, - messageId: MESSAGE_ENDS_WITH - }); - } + context.report({ + node, + messageId: result.messageId + }); } }; }; @@ -73,6 +118,7 @@ module.exports = { messages: { [MESSAGE_STARTS_WITH]: 'Prefer `String#startsWith()` over a regex with `^`.', [MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.' - } + }, + fixable: 'code' } }; diff --git a/test/prefer-starts-ends-with.js b/test/prefer-starts-ends-with.js index 25984a94e7..774afe1df7 100644 --- a/test/prefer-starts-ends-with.js +++ b/test/prefer-starts-ends-with.js @@ -1,4 +1,5 @@ import test from 'ava'; +import {outdent} from 'outdent'; import avaRuleTester from 'eslint-ava-rule-tester'; import rule from '../rules/prefer-starts-ends-with'; @@ -53,14 +54,78 @@ ruleTester.run('prefer-starts-ends-with', rule, { ], invalid: [ ...invalidRegex.map(re => { - const code = `${re}.test(bar)`; - const messageId = re.source.startsWith('^') ? MESSAGE_STARTS_WITH : MESSAGE_ENDS_WITH; + let messageId = MESSAGE_STARTS_WITH; + let method = 'startsWith'; + let string = re.source; + + if (string.startsWith('^')) { + string = string.slice(1); + } else { + messageId = MESSAGE_ENDS_WITH; + method = 'endsWith'; + string = string.slice(0, -1); + } + return { - code, - output: code, + code: `${re}.test(bar)`, + output: `bar.${method}('${string}')`, errors: [{messageId}] }; }), + // Parenthesized + { + code: '/^b/.test(("a"))', + output: '("a").startsWith((\'b\'))', + errors: [{messageId: MESSAGE_STARTS_WITH}] + }, + { + code: '(/^b/).test(("a"))', + output: '("a").startsWith((\'b\'))', + errors: [{messageId: MESSAGE_STARTS_WITH}] + }, + { + code: 'const fn = async () => /^b/.test(await foo)', + output: 'const fn = async () => (await foo).startsWith(\'b\')', + errors: [{messageId: MESSAGE_STARTS_WITH}] + }, + { + code: 'const fn = async () => (/^b/).test(await foo)', + output: 'const fn = async () => (await foo).startsWith(\'b\')', + errors: [{messageId: MESSAGE_STARTS_WITH}] + }, + // Comments + { + code: outdent` + if ( + /* comment 1 */ + /^b/ + /* comment 2 */ + .test + /* comment 3 */ + ( + /* comment 4 */ + foo + /* comment 5 */ + ) + ) {} + `, + output: outdent` + if ( + /* comment 1 */ + foo + /* comment 2 */ + .startsWith + /* comment 3 */ + ( + /* comment 4 */ + 'b' + /* comment 5 */ + ) + ) {} + `, + errors: [{messageId: MESSAGE_STARTS_WITH}] + }, + ...invalidRegex.map(re => { const code = `bar.match(${re})`; const messageId = re.source.startsWith('^') ? MESSAGE_STARTS_WITH : MESSAGE_ENDS_WITH;