Skip to content

Commit 458dfdb

Browse files
committed
feat: add suggestions for prefer-string-starts-ends-with rule
1 parent 2724afa commit 458dfdb

File tree

5 files changed

+435
-31
lines changed

5 files changed

+435
-31
lines changed

docs/rules/prefer-string-starts-ends-with.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ Prefer [`String#startsWith()`](https://developer.mozilla.org/en/docs/Web/JavaScr
44

55
This rule is fixable.
66

7+
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.
8+
79
## Fail
810

911
```js
@@ -24,6 +26,18 @@ const foo = baz.startsWith('bar');
2426
const foo = baz.endsWith('bar');
2527
```
2628

29+
```js
30+
const foo = baz?.startsWith('bar');
31+
```
32+
33+
```js
34+
const foo = (baz ?? '').startsWith('bar');
35+
```
36+
37+
```js
38+
const foo = String(baz).startsWith('bar');
39+
```
40+
2741
```js
2842
const foo = /^bar/i.test(baz);
2943
```

rules/prefer-string-starts-ends-with.js

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@ const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add
77

88
const MESSAGE_STARTS_WITH = 'prefer-starts-with';
99
const MESSAGE_ENDS_WITH = 'prefer-ends-with';
10+
const SUGGEST_STRING_CAST = 'suggest-string-cast';
11+
const SUGGEST_OPTIONAL_CHAINING = 'suggest-optional-chaining';
12+
const SUGGEST_NULLISH_COALESCING = 'suggest-nullish-coalescing';
1013
const messages = {
1114
[MESSAGE_STARTS_WITH]: 'Prefer `String#startsWith()` over a regex with `^`.',
12-
[MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.'
15+
[MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.',
16+
[SUGGEST_STRING_CAST]: 'For strings that may be `undefined` / `null`, use string casting.',
17+
[SUGGEST_OPTIONAL_CHAINING]: 'For strings that may be `undefined` / `null`, use optional chaining.',
18+
[SUGGEST_NULLISH_COALESCING]: 'For strings that may be `undefined` / `null`, use nullish coalescing.'
1319
};
1420

1521
const doesNotContain = (string, characters) => characters.every(character => !string.includes(character));
@@ -64,33 +70,62 @@ const create = context => {
6470
return;
6571
}
6672

73+
function fix(fixer, {useNullishCoalescing, useOptionalChaining, useStringCasting} = {}) {
74+
const method = result.messageId === MESSAGE_STARTS_WITH ? 'startsWith' : 'endsWith';
75+
const [target] = node.arguments;
76+
let targetString = sourceCode.getText(target);
77+
const isRegexParenthesized = isParenthesized(regexNode, sourceCode);
78+
const isTargetParenthesized = isParenthesized(target, sourceCode);
79+
80+
if (
81+
// If regex is parenthesized, we can use it, so we don't need add again
82+
!isRegexParenthesized &&
83+
(isTargetParenthesized || shouldAddParenthesesToMemberExpressionObject(target, sourceCode))
84+
) {
85+
targetString = `(${targetString})`;
86+
}
87+
88+
if (useNullishCoalescing) {
89+
// (target ?? '').startsWith(pattern)
90+
targetString = (isRegexParenthesized ? '' : '(') + targetString + ' ?? \'\'' + (isRegexParenthesized ? '' : ')');
91+
} else if (useStringCasting) {
92+
// String(target).startsWith(pattern)
93+
const isTargetStringParenthesized = targetString.startsWith('(');
94+
targetString = 'String' + (isTargetStringParenthesized ? '' : '(') + targetString + (isTargetStringParenthesized ? '' : ')');
95+
}
96+
97+
// The regex literal always starts with `/` or `(`, so we don't need check ASI
98+
99+
return [
100+
// Replace regex with string
101+
fixer.replaceText(regexNode, targetString),
102+
// `.test` => `.startsWith` / `.endsWith`
103+
fixer.replaceText(node.callee.property, method),
104+
// Optional chaining: target.startsWith => target?.startsWith
105+
useOptionalChaining ? fixer.replaceText(sourceCode.getTokenBefore(node.callee.property), '?.') : undefined,
106+
// Replace argument with result.string
107+
fixer.replaceText(target, quoteString(result.string))
108+
].filter(Boolean);
109+
}
110+
67111
context.report({
68112
node,
69113
messageId: result.messageId,
70-
fix: fixer => {
71-
const method = result.messageId === MESSAGE_STARTS_WITH ? 'startsWith' : 'endsWith';
72-
const [target] = node.arguments;
73-
let targetString = sourceCode.getText(target);
74-
75-
if (
76-
// If regex is parenthesized, we can use it, so we don't need add again
77-
!isParenthesized(regexNode, sourceCode) &&
78-
(isParenthesized(target, sourceCode) || shouldAddParenthesesToMemberExpressionObject(target, sourceCode))
79-
) {
80-
targetString = `(${targetString})`;
114+
suggest: [
115+
{
116+
messageId: SUGGEST_STRING_CAST,
117+
fix: fixer => fix(fixer, {useStringCasting: true})
118+
},
119+
{
120+
messageId: SUGGEST_OPTIONAL_CHAINING,
121+
fix: fixer => fix(fixer, {useOptionalChaining: true})
122+
},
123+
{
124+
messageId: SUGGEST_NULLISH_COALESCING,
125+
fix: fixer => fix(fixer, {useNullishCoalescing: true})
81126
}
82-
83-
// The regex literal always starts with `/` or `(`, so we don't need check ASI
84-
85-
return [
86-
// Replace regex with string
87-
fixer.replaceText(regexNode, targetString),
88-
// `.test` => `.startsWith` / `.endsWith`
89-
fixer.replaceText(node.callee.property, method),
90-
// Replace argument with result.string
91-
fixer.replaceText(target, quoteString(result.string))
92-
];
93-
}
127+
],
128+
fix
94129
});
95130
}
96131
};
@@ -102,7 +137,8 @@ module.exports = {
102137
type: 'suggestion',
103138
docs: {
104139
description: 'Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`.',
105-
url: getDocumentationUrl(__filename)
140+
url: getDocumentationUrl(__filename),
141+
suggest: true
106142
},
107143
messages,
108144
fixable: 'code',

test/prefer-string-starts-ends-with.mjs

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const {test} = getTester(import.meta);
55

66
const MESSAGE_STARTS_WITH = 'prefer-starts-with';
77
const MESSAGE_ENDS_WITH = 'prefer-ends-with';
8+
const SUGGEST_STRING_CAST = 'suggest-string-cast';
9+
const SUGGEST_OPTIONAL_CHAINING = 'suggest-optional-chaining';
10+
const SUGGEST_NULLISH_COALESCING = 'suggest-nullish-coalescing';
811

912
const validRegex = [
1013
/foo/,
@@ -70,29 +73,109 @@ test({
7073
return {
7174
code: `${re}.test(bar)`,
7275
output: `bar.${method}('${string}')`,
73-
errors: [{messageId}]
76+
errors: [{
77+
messageId,
78+
suggestions: [
79+
{
80+
messageId: SUGGEST_STRING_CAST,
81+
output: `String(bar).${method}('${string}')`
82+
},
83+
{
84+
messageId: SUGGEST_OPTIONAL_CHAINING,
85+
output: `bar?.${method}('${string}')`
86+
},
87+
{
88+
messageId: SUGGEST_NULLISH_COALESCING,
89+
output: `(bar ?? '').${method}('${string}')`
90+
}
91+
]
92+
}]
7493
};
7594
}),
7695
// Parenthesized
7796
{
7897
code: '/^b/.test(("a"))',
7998
output: '("a").startsWith((\'b\'))',
80-
errors: [{messageId: MESSAGE_STARTS_WITH}]
99+
errors: [{
100+
messageId: MESSAGE_STARTS_WITH,
101+
suggestions: [
102+
{
103+
messageId: SUGGEST_STRING_CAST,
104+
output: 'String("a").startsWith((\'b\'))'
105+
},
106+
{
107+
messageId: SUGGEST_OPTIONAL_CHAINING,
108+
output: '("a")?.startsWith((\'b\'))'
109+
},
110+
{
111+
messageId: SUGGEST_NULLISH_COALESCING,
112+
output: '(("a") ?? \'\').startsWith((\'b\'))'
113+
}
114+
]
115+
}]
81116
},
82117
{
83118
code: '(/^b/).test(("a"))',
84119
output: '("a").startsWith((\'b\'))',
85-
errors: [{messageId: MESSAGE_STARTS_WITH}]
120+
errors: [{
121+
messageId: MESSAGE_STARTS_WITH,
122+
suggestions: [
123+
{
124+
messageId: SUGGEST_STRING_CAST,
125+
output: '(String("a")).startsWith((\'b\'))' // TODO: remove extra parens around String()
126+
},
127+
{
128+
messageId: SUGGEST_OPTIONAL_CHAINING,
129+
output: '("a")?.startsWith((\'b\'))'
130+
},
131+
{
132+
messageId: SUGGEST_NULLISH_COALESCING,
133+
output: '("a" ?? \'\').startsWith((\'b\'))'
134+
}
135+
]
136+
}]
86137
},
87138
{
88139
code: 'const fn = async () => /^b/.test(await foo)',
89140
output: 'const fn = async () => (await foo).startsWith(\'b\')',
90-
errors: [{messageId: MESSAGE_STARTS_WITH}]
141+
errors: [{
142+
messageId: MESSAGE_STARTS_WITH,
143+
suggestions: [
144+
{
145+
messageId: SUGGEST_STRING_CAST,
146+
output: 'const fn = async () => String(await foo).startsWith(\'b\')'
147+
},
148+
{
149+
messageId: SUGGEST_OPTIONAL_CHAINING,
150+
output: 'const fn = async () => (await foo)?.startsWith(\'b\')'
151+
},
152+
{
153+
messageId: SUGGEST_NULLISH_COALESCING,
154+
output: 'const fn = async () => ((await foo) ?? \'\').startsWith(\'b\')'
155+
}
156+
]
157+
}]
91158
},
92159
{
93160
code: 'const fn = async () => (/^b/).test(await foo)',
94161
output: 'const fn = async () => (await foo).startsWith(\'b\')',
95-
errors: [{messageId: MESSAGE_STARTS_WITH}]
162+
errors: [{
163+
messageId: MESSAGE_STARTS_WITH,
164+
suggestions: [
165+
{
166+
messageId: SUGGEST_STRING_CAST,
167+
output: 'const fn = async () => (String(await foo)).startsWith(\'b\')'
168+
},
169+
{
170+
messageId: SUGGEST_OPTIONAL_CHAINING,
171+
output: 'const fn = async () => (await foo)?.startsWith(\'b\')'
172+
},
173+
{
174+
messageId: SUGGEST_NULLISH_COALESCING,
175+
output: 'const fn = async () => (await foo ?? \'\').startsWith(\'b\')'
176+
}
177+
]
178+
}]
96179
},
97180
// Comments
98181
{
@@ -124,7 +207,62 @@ test({
124207
)
125208
) {}
126209
`,
127-
errors: [{messageId: MESSAGE_STARTS_WITH}]
210+
errors: [{
211+
messageId: MESSAGE_STARTS_WITH,
212+
suggestions: [
213+
{
214+
messageId: SUGGEST_STRING_CAST,
215+
output: outdent`
216+
if (
217+
/* comment 1 */
218+
String(foo)
219+
/* comment 2 */
220+
.startsWith
221+
/* comment 3 */
222+
(
223+
/* comment 4 */
224+
'b'
225+
/* comment 5 */
226+
)
227+
) {}
228+
`
229+
},
230+
{
231+
messageId: SUGGEST_OPTIONAL_CHAINING,
232+
output: outdent`
233+
if (
234+
/* comment 1 */
235+
foo
236+
/* comment 2 */
237+
?.startsWith
238+
/* comment 3 */
239+
(
240+
/* comment 4 */
241+
'b'
242+
/* comment 5 */
243+
)
244+
) {}
245+
`
246+
},
247+
{
248+
messageId: SUGGEST_NULLISH_COALESCING,
249+
output: outdent`
250+
if (
251+
/* comment 1 */
252+
(foo ?? '')
253+
/* comment 2 */
254+
.startsWith
255+
/* comment 3 */
256+
(
257+
/* comment 4 */
258+
'b'
259+
/* comment 5 */
260+
)
261+
) {}
262+
`
263+
}
264+
]
265+
}]
128266
}
129267
]
130268
});

0 commit comments

Comments
 (0)