Skip to content

Commit

Permalink
feat: add suggestions for prefer-string-starts-ends-with rule
Browse files Browse the repository at this point in the history
  • Loading branch information
bmish committed May 14, 2021
1 parent 2724afa commit 458dfdb
Show file tree
Hide file tree
Showing 5 changed files with 435 additions and 31 deletions.
14 changes: 14 additions & 0 deletions docs/rules/prefer-string-starts-ends-with.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Prefer [`String#startsWith()`](https://developer.mozilla.org/en/docs/Web/JavaScr

This rule is fixable.

Note: the autofix will throw an exception when the string being tested is `null` or `undefined`. Several safer but more verbose automatic suggestions are provided for this situation.

## Fail

```js
Expand All @@ -24,6 +26,18 @@ const foo = baz.startsWith('bar');
const foo = baz.endsWith('bar');
```

```js
const foo = baz?.startsWith('bar');
```
```js
const foo = (baz ?? '').startsWith('bar');
```
```js
const foo = String(baz).startsWith('bar');
```
```js
const foo = /^bar/i.test(baz);
```
86 changes: 61 additions & 25 deletions rules/prefer-string-starts-ends-with.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add

const MESSAGE_STARTS_WITH = 'prefer-starts-with';
const MESSAGE_ENDS_WITH = 'prefer-ends-with';
const SUGGEST_STRING_CAST = 'suggest-string-cast';
const SUGGEST_OPTIONAL_CHAINING = 'suggest-optional-chaining';
const SUGGEST_NULLISH_COALESCING = 'suggest-nullish-coalescing';
const messages = {
[MESSAGE_STARTS_WITH]: 'Prefer `String#startsWith()` over a regex with `^`.',
[MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.'
[MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.',
[SUGGEST_STRING_CAST]: 'For strings that may be `undefined` / `null`, use string casting.',
[SUGGEST_OPTIONAL_CHAINING]: 'For strings that may be `undefined` / `null`, use optional chaining.',
[SUGGEST_NULLISH_COALESCING]: 'For strings that may be `undefined` / `null`, use nullish coalescing.'
};

const doesNotContain = (string, characters) => characters.every(character => !string.includes(character));
Expand Down Expand Up @@ -64,33 +70,62 @@ const create = context => {
return;
}

function fix(fixer, {useNullishCoalescing, useOptionalChaining, useStringCasting} = {}) {
const method = result.messageId === MESSAGE_STARTS_WITH ? 'startsWith' : 'endsWith';
const [target] = node.arguments;
let targetString = sourceCode.getText(target);
const isRegexParenthesized = isParenthesized(regexNode, sourceCode);
const isTargetParenthesized = isParenthesized(target, sourceCode);

if (
// If regex is parenthesized, we can use it, so we don't need add again
!isRegexParenthesized &&
(isTargetParenthesized || shouldAddParenthesesToMemberExpressionObject(target, sourceCode))
) {
targetString = `(${targetString})`;
}

if (useNullishCoalescing) {
// (target ?? '').startsWith(pattern)
targetString = (isRegexParenthesized ? '' : '(') + targetString + ' ?? \'\'' + (isRegexParenthesized ? '' : ')');
} else if (useStringCasting) {
// String(target).startsWith(pattern)
const isTargetStringParenthesized = targetString.startsWith('(');
targetString = 'String' + (isTargetStringParenthesized ? '' : '(') + targetString + (isTargetStringParenthesized ? '' : ')');
}

// 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),
// Optional chaining: target.startsWith => target?.startsWith
useOptionalChaining ? fixer.replaceText(sourceCode.getTokenBefore(node.callee.property), '?.') : undefined,
// Replace argument with result.string
fixer.replaceText(target, quoteString(result.string))
].filter(Boolean);
}

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) || shouldAddParenthesesToMemberExpressionObject(target, sourceCode))
) {
targetString = `(${targetString})`;
suggest: [
{
messageId: SUGGEST_STRING_CAST,
fix: fixer => fix(fixer, {useStringCasting: true})
},
{
messageId: SUGGEST_OPTIONAL_CHAINING,
fix: fixer => fix(fixer, {useOptionalChaining: true})
},
{
messageId: SUGGEST_NULLISH_COALESCING,
fix: fixer => fix(fixer, {useNullishCoalescing: true})
}

// 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))
];
}
],
fix
});
}
};
Expand All @@ -102,7 +137,8 @@ module.exports = {
type: 'suggestion',
docs: {
description: 'Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`.',
url: getDocumentationUrl(__filename)
url: getDocumentationUrl(__filename),
suggest: true
},
messages,
fixable: 'code',
Expand Down
150 changes: 144 additions & 6 deletions test/prefer-string-starts-ends-with.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const {test} = getTester(import.meta);

const MESSAGE_STARTS_WITH = 'prefer-starts-with';
const MESSAGE_ENDS_WITH = 'prefer-ends-with';
const SUGGEST_STRING_CAST = 'suggest-string-cast';
const SUGGEST_OPTIONAL_CHAINING = 'suggest-optional-chaining';
const SUGGEST_NULLISH_COALESCING = 'suggest-nullish-coalescing';

const validRegex = [
/foo/,
Expand Down Expand Up @@ -70,29 +73,109 @@ test({
return {
code: `${re}.test(bar)`,
output: `bar.${method}('${string}')`,
errors: [{messageId}]
errors: [{
messageId,
suggestions: [
{
messageId: SUGGEST_STRING_CAST,
output: `String(bar).${method}('${string}')`
},
{
messageId: SUGGEST_OPTIONAL_CHAINING,
output: `bar?.${method}('${string}')`
},
{
messageId: SUGGEST_NULLISH_COALESCING,
output: `(bar ?? '').${method}('${string}')`
}
]
}]
};
}),
// Parenthesized
{
code: '/^b/.test(("a"))',
output: '("a").startsWith((\'b\'))',
errors: [{messageId: MESSAGE_STARTS_WITH}]
errors: [{
messageId: MESSAGE_STARTS_WITH,
suggestions: [
{
messageId: SUGGEST_STRING_CAST,
output: 'String("a").startsWith((\'b\'))'
},
{
messageId: SUGGEST_OPTIONAL_CHAINING,
output: '("a")?.startsWith((\'b\'))'
},
{
messageId: SUGGEST_NULLISH_COALESCING,
output: '(("a") ?? \'\').startsWith((\'b\'))'
}
]
}]
},
{
code: '(/^b/).test(("a"))',
output: '("a").startsWith((\'b\'))',
errors: [{messageId: MESSAGE_STARTS_WITH}]
errors: [{
messageId: MESSAGE_STARTS_WITH,
suggestions: [
{
messageId: SUGGEST_STRING_CAST,
output: '(String("a")).startsWith((\'b\'))' // TODO: remove extra parens around String()
},
{
messageId: SUGGEST_OPTIONAL_CHAINING,
output: '("a")?.startsWith((\'b\'))'
},
{
messageId: SUGGEST_NULLISH_COALESCING,
output: '("a" ?? \'\').startsWith((\'b\'))'
}
]
}]
},
{
code: 'const fn = async () => /^b/.test(await foo)',
output: 'const fn = async () => (await foo).startsWith(\'b\')',
errors: [{messageId: MESSAGE_STARTS_WITH}]
errors: [{
messageId: MESSAGE_STARTS_WITH,
suggestions: [
{
messageId: SUGGEST_STRING_CAST,
output: 'const fn = async () => String(await foo).startsWith(\'b\')'
},
{
messageId: SUGGEST_OPTIONAL_CHAINING,
output: 'const fn = async () => (await foo)?.startsWith(\'b\')'
},
{
messageId: SUGGEST_NULLISH_COALESCING,
output: 'const fn = async () => ((await foo) ?? \'\').startsWith(\'b\')'
}
]
}]
},
{
code: 'const fn = async () => (/^b/).test(await foo)',
output: 'const fn = async () => (await foo).startsWith(\'b\')',
errors: [{messageId: MESSAGE_STARTS_WITH}]
errors: [{
messageId: MESSAGE_STARTS_WITH,
suggestions: [
{
messageId: SUGGEST_STRING_CAST,
output: 'const fn = async () => (String(await foo)).startsWith(\'b\')'
},
{
messageId: SUGGEST_OPTIONAL_CHAINING,
output: 'const fn = async () => (await foo)?.startsWith(\'b\')'
},
{
messageId: SUGGEST_NULLISH_COALESCING,
output: 'const fn = async () => (await foo ?? \'\').startsWith(\'b\')'
}
]
}]
},
// Comments
{
Expand Down Expand Up @@ -124,7 +207,62 @@ test({
)
) {}
`,
errors: [{messageId: MESSAGE_STARTS_WITH}]
errors: [{
messageId: MESSAGE_STARTS_WITH,
suggestions: [
{
messageId: SUGGEST_STRING_CAST,
output: outdent`
if (
/* comment 1 */
String(foo)
/* comment 2 */
.startsWith
/* comment 3 */
(
/* comment 4 */
'b'
/* comment 5 */
)
) {}
`
},
{
messageId: SUGGEST_OPTIONAL_CHAINING,
output: outdent`
if (
/* comment 1 */
foo
/* comment 2 */
?.startsWith
/* comment 3 */
(
/* comment 4 */
'b'
/* comment 5 */
)
) {}
`
},
{
messageId: SUGGEST_NULLISH_COALESCING,
output: outdent`
if (
/* comment 1 */
(foo ?? '')
/* comment 2 */
.startsWith
/* comment 3 */
(
/* comment 4 */
'b'
/* comment 5 */
)
) {}
`
}
]
}]
}
]
});
Expand Down

0 comments on commit 458dfdb

Please sign in to comment.