Skip to content

Commit fd37bc3

Browse files
authored
feat(eslint-plugin): [pref-str-starts/ends-with] optional chain… (#1357)
* feat(eslint-plugin): [pref-str-starts/ends-with] optional chain support * chore: switch already fixed rules to `:matches`
1 parent 099225a commit fd37bc3

File tree

5 files changed

+149
-53
lines changed

5 files changed

+149
-53
lines changed

Diff for: packages/eslint-plugin/src/rules/no-require-imports.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export default util.createRule({
1818
defaultOptions: [],
1919
create(context) {
2020
return {
21-
'CallExpression > Identifier[name="require"], OptionalCallExpression > Identifier[name="require"]'(
21+
':matches(CallExpression, OptionalCallExpression) > Identifier[name="require"]'(
2222
node: TSESTree.Identifier,
2323
): void {
2424
context.report({

Diff for: packages/eslint-plugin/src/rules/prefer-includes.ts

+2-12
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,7 @@ export default createRule({
122122
}
123123

124124
return {
125-
[[
126-
"BinaryExpression > CallExpression.left > MemberExpression.callee[property.name='indexOf'][computed=false]",
127-
"BinaryExpression > OptionalCallExpression.left > MemberExpression.callee[property.name='indexOf'][computed=false]",
128-
"BinaryExpression > CallExpression.left > OptionalMemberExpression.callee[property.name='indexOf'][computed=false]",
129-
"BinaryExpression > OptionalCallExpression.left > OptionalMemberExpression.callee[property.name='indexOf'][computed=false]",
130-
].join(', ')](
125+
"BinaryExpression > :matches(CallExpression, OptionalCallExpression).left > :matches(MemberExpression, OptionalMemberExpression).callee[property.name='indexOf'][computed=false]"(
131126
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
132127
): void {
133128
// Check if the comparison is equivalent to `includes()`.
@@ -181,12 +176,7 @@ export default createRule({
181176
},
182177

183178
// /bar/.test(foo)
184-
[[
185-
'CallExpression > MemberExpression.callee[property.name="test"][computed=false]',
186-
'OptionalCallExpression > MemberExpression.callee[property.name="test"][computed=false]',
187-
'CallExpression > OptionalMemberExpression.callee[property.name="test"][computed=false]',
188-
'OptionalCallExpression > OptionalMemberExpression.callee[property.name="test"][computed=false]',
189-
].join(', ')](
179+
':matches(CallExpression, OptionalCallExpression) > :matches(MemberExpression, OptionalMemberExpression).callee[property.name="test"][computed=false]'(
190180
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
191181
): void {
192182
const callNode = node.parent as

Diff for: packages/eslint-plugin/src/rules/prefer-string-starts-ends-with.ts

+77-36
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,10 @@ export default createRule({
143143
node: TSESTree.Node,
144144
expectedObjectNode: TSESTree.Node,
145145
): boolean {
146-
if (node.type === AST_NODE_TYPES.MemberExpression) {
146+
if (
147+
node.type === AST_NODE_TYPES.MemberExpression ||
148+
node.type === AST_NODE_TYPES.OptionalMemberExpression
149+
) {
147150
return (
148151
getPropertyName(node, globalScope) === 'length' &&
149152
isSameTokens(node.object, expectedObjectNode)
@@ -191,7 +194,7 @@ export default createRule({
191194
* @param node The member expression node to get.
192195
*/
193196
function getPropertyRange(
194-
node: TSESTree.MemberExpression,
197+
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
195198
): [number, number] {
196199
const dotOrOpenBracket = sourceCode.getTokenAfter(
197200
node.object,
@@ -269,26 +272,30 @@ export default createRule({
269272
* @param fixer The rule fixer.
270273
* @param node The node which was reported.
271274
* @param kind The kind of the report.
272-
* @param negative The flag to fix to negative condition.
275+
* @param isNegative The flag to fix to negative condition.
273276
*/
274277
function* fixWithRightOperand(
275278
fixer: TSESLint.RuleFixer,
276279
node: TSESTree.BinaryExpression,
277280
kind: 'start' | 'end',
278-
negative: boolean,
281+
isNegative: boolean,
282+
isOptional: boolean,
279283
): IterableIterator<TSESLint.RuleFix> {
280284
// left is CallExpression or MemberExpression.
281-
const leftNode = (node.left.type === AST_NODE_TYPES.CallExpression
285+
const leftNode = (node.left.type === AST_NODE_TYPES.CallExpression ||
286+
node.left.type === AST_NODE_TYPES.OptionalCallExpression
282287
? node.left.callee
283-
: node.left) as TSESTree.MemberExpression;
288+
: node.left) as
289+
| TSESTree.MemberExpression
290+
| TSESTree.OptionalMemberExpression;
284291
const propertyRange = getPropertyRange(leftNode);
285292

286-
if (negative) {
293+
if (isNegative) {
287294
yield fixer.insertTextBefore(node, '!');
288295
}
289296
yield fixer.replaceTextRange(
290297
[propertyRange[0], node.right.range[0]],
291-
`.${kind}sWith(`,
298+
`${isOptional ? '?.' : '.'}${kind}sWith(`,
292299
);
293300
yield fixer.replaceTextRange([node.right.range[1], node.range[1]], ')');
294301
}
@@ -306,16 +313,21 @@ export default createRule({
306313
node: TSESTree.BinaryExpression,
307314
kind: 'start' | 'end',
308315
negative: boolean,
316+
isOptional: boolean,
309317
): IterableIterator<TSESLint.RuleFix> {
310-
const callNode = node.left as TSESTree.CallExpression;
311-
const calleeNode = callNode.callee as TSESTree.MemberExpression;
318+
const callNode = node.left as
319+
| TSESTree.CallExpression
320+
| TSESTree.OptionalCallExpression;
321+
const calleeNode = callNode.callee as
322+
| TSESTree.MemberExpression
323+
| TSESTree.OptionalMemberExpression;
312324

313325
if (negative) {
314326
yield fixer.insertTextBefore(node, '!');
315327
}
316328
yield fixer.replaceTextRange(
317329
getPropertyRange(calleeNode),
318-
`.${kind}sWith`,
330+
`${isOptional ? '?.' : '.'}${kind}sWith`,
319331
);
320332
yield fixer.removeRange([callNode.range[1], node.range[1]]);
321333
}
@@ -325,13 +337,18 @@ export default createRule({
325337
// foo.charAt(0) === "a"
326338
// foo[foo.length - 1] === "a"
327339
// foo.charAt(foo.length - 1) === "a"
328-
[String([
329-
'BinaryExpression > MemberExpression.left[computed=true]',
330-
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="charAt"][computed=false]',
331-
])](node: TSESTree.MemberExpression): void {
340+
[[
341+
'BinaryExpression > :matches(MemberExpression, OptionalMemberExpression).left[computed=true]',
342+
'BinaryExpression > :matches(CallExpression, OptionalCallExpression).left > :matches(MemberExpression, OptionalMemberExpression).callee[property.name="charAt"][computed=false]',
343+
].join(', ')](
344+
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
345+
): void {
332346
let parentNode = node.parent!;
333347
let indexNode: TSESTree.Node | null = null;
334-
if (parentNode.type === AST_NODE_TYPES.CallExpression) {
348+
if (
349+
parentNode.type === AST_NODE_TYPES.CallExpression ||
350+
parentNode.type === AST_NODE_TYPES.OptionalCallExpression
351+
) {
335352
if (parentNode.arguments.length === 1) {
336353
indexNode = parentNode.arguments[0];
337354
}
@@ -368,16 +385,19 @@ export default createRule({
368385
eqNode,
369386
isStartsWith ? 'start' : 'end',
370387
eqNode.operator.startsWith('!'),
388+
node.optional,
371389
);
372390
},
373391
});
374392
},
375393

376394
// foo.indexOf('bar') === 0
377-
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="indexOf"][computed=false]'(
378-
node: TSESTree.MemberExpression,
395+
'BinaryExpression > :matches(CallExpression, OptionalCallExpression).left > :matches(MemberExpression, OptionalMemberExpression).callee[property.name="indexOf"][computed=false]'(
396+
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
379397
): void {
380-
const callNode = node.parent! as TSESTree.CallExpression;
398+
const callNode = node.parent as
399+
| TSESTree.CallExpression
400+
| TSESTree.OptionalCallExpression;
381401
const parentNode = callNode.parent!;
382402

383403
if (
@@ -399,17 +419,20 @@ export default createRule({
399419
parentNode,
400420
'start',
401421
parentNode.operator.startsWith('!'),
422+
node.optional,
402423
);
403424
},
404425
});
405426
},
406427

407428
// foo.lastIndexOf('bar') === foo.length - 3
408429
// foo.lastIndexOf(bar) === foo.length - bar.length
409-
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="lastIndexOf"][computed=false]'(
410-
node: TSESTree.MemberExpression,
430+
'BinaryExpression > :matches(CallExpression, OptionalCallExpression).left > :matches(MemberExpression, OptionalMemberExpression).callee[property.name="lastIndexOf"][computed=false]'(
431+
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
411432
): void {
412-
const callNode = node.parent! as TSESTree.CallExpression;
433+
const callNode = node.parent! as
434+
| TSESTree.CallExpression
435+
| TSESTree.OptionalCallExpression;
413436
const parentNode = callNode.parent!;
414437

415438
if (
@@ -434,17 +457,20 @@ export default createRule({
434457
parentNode,
435458
'end',
436459
parentNode.operator.startsWith('!'),
460+
node.optional,
437461
);
438462
},
439463
});
440464
},
441465

442466
// foo.match(/^bar/) === null
443467
// foo.match(/bar$/) === null
444-
'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="match"][computed=false]'(
445-
node: TSESTree.MemberExpression,
468+
'BinaryExpression > :matches(CallExpression, OptionalCallExpression).left > :matches(MemberExpression, OptionalMemberExpression).callee[property.name="match"][computed=false]'(
469+
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
446470
): void {
447-
const callNode = node.parent as TSESTree.CallExpression;
471+
const callNode = node.parent as
472+
| TSESTree.CallExpression
473+
| TSESTree.OptionalCallExpression;
448474
const parentNode = callNode.parent as TSESTree.BinaryExpression;
449475
if (
450476
!isEqualityComparison(parentNode) ||
@@ -472,7 +498,9 @@ export default createRule({
472498
}
473499
yield fixer.replaceTextRange(
474500
getPropertyRange(node),
475-
`.${isStartsWith ? 'start' : 'end'}sWith`,
501+
`${node.optional ? '?.' : '.'}${
502+
isStartsWith ? 'start' : 'end'
503+
}sWith`,
476504
);
477505
yield fixer.replaceText(
478506
callNode.arguments[0],
@@ -489,11 +517,15 @@ export default createRule({
489517
// foo.substring(0, 3) === 'bar'
490518
// foo.substring(foo.length - 3) === 'bar'
491519
// foo.substring(foo.length - 3, foo.length) === 'bar'
492-
[String([
493-
'CallExpression > MemberExpression.callee[property.name=slice][computed=false]',
494-
'CallExpression > MemberExpression.callee[property.name=substring][computed=false]',
495-
])](node: TSESTree.MemberExpression): void {
496-
const callNode = node.parent! as TSESTree.CallExpression;
520+
[[
521+
':matches(CallExpression, OptionalCallExpression) > :matches(MemberExpression, OptionalMemberExpression).callee[property.name="slice"][computed=false]',
522+
':matches(CallExpression, OptionalCallExpression) > :matches(MemberExpression, OptionalMemberExpression).callee[property.name="substring"][computed=false]',
523+
].join(', ')](
524+
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
525+
): void {
526+
const callNode = node.parent! as
527+
| TSESTree.CallExpression
528+
| TSESTree.OptionalCallExpression;
497529
const parentNode = callNode.parent!;
498530
if (
499531
!isEqualityComparison(parentNode) ||
@@ -555,17 +587,20 @@ export default createRule({
555587
parentNode,
556588
isStartsWith ? 'start' : 'end',
557589
parentNode.operator.startsWith('!'),
590+
node.optional,
558591
);
559592
},
560593
});
561594
},
562595

563596
// /^bar/.test(foo)
564597
// /bar$/.test(foo)
565-
'CallExpression > MemberExpression.callee[property.name="test"][computed=false]'(
566-
node: TSESTree.MemberExpression,
598+
':matches(CallExpression, OptionalCallExpression) > :matches(MemberExpression, OptionalMemberExpression).callee[property.name="test"][computed=false]'(
599+
node: TSESTree.MemberExpression | TSESTree.OptionalMemberExpression,
567600
): void {
568-
const callNode = node.parent as TSESTree.CallExpression;
601+
const callNode = node.parent as
602+
| TSESTree.CallExpression
603+
| TSESTree.OptionalCallExpression;
569604
const parsed =
570605
callNode.arguments.length === 1 ? parseRegExp(node.object) : null;
571606
if (parsed == null) {
@@ -585,7 +620,9 @@ export default createRule({
585620
argNode.type !== AST_NODE_TYPES.TemplateLiteral &&
586621
argNode.type !== AST_NODE_TYPES.Identifier &&
587622
argNode.type !== AST_NODE_TYPES.MemberExpression &&
588-
argNode.type !== AST_NODE_TYPES.CallExpression;
623+
argNode.type !== AST_NODE_TYPES.OptionalMemberExpression &&
624+
argNode.type !== AST_NODE_TYPES.CallExpression &&
625+
argNode.type !== AST_NODE_TYPES.OptionalCallExpression;
589626

590627
yield fixer.removeRange([callNode.range[0], argNode.range[0]]);
591628
if (needsParen) {
@@ -594,7 +631,11 @@ export default createRule({
594631
}
595632
yield fixer.insertTextAfter(
596633
argNode,
597-
`.${methodName}(${JSON.stringify(text)}`,
634+
`${
635+
callNode.type === AST_NODE_TYPES.OptionalCallExpression
636+
? '?.'
637+
: '.'
638+
}${methodName}(${JSON.stringify(text)}`,
598639
);
599640
},
600641
});

Diff for: packages/eslint-plugin/tests/rules/prefer-string-starts-ends-with.test.ts

+68-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { TSESLint } from '@typescript-eslint/experimental-utils';
12
import path from 'path';
23
import rule from '../../src/rules/prefer-string-starts-ends-with';
34
import { RuleTester } from '../RuleTester';
@@ -13,7 +14,7 @@ const ruleTester = new RuleTester({
1314
});
1415

1516
ruleTester.run('prefer-string-starts-ends-with', rule, {
16-
valid: [
17+
valid: addOptional([
1718
`
1819
function f(s: string[]) {
1920
s[0] === "a"
@@ -224,8 +225,8 @@ ruleTester.run('prefer-string-starts-ends-with', rule, {
224225
x.test(s)
225226
}
226227
`,
227-
],
228-
invalid: [
228+
]),
229+
invalid: addOptional([
229230
// String indexing.
230231
{
231232
code: `
@@ -1042,5 +1043,68 @@ ruleTester.run('prefer-string-starts-ends-with', rule, {
10421043
`,
10431044
errors: [{ messageId: 'preferStartsWith' }],
10441045
},
1045-
],
1046+
]),
10461047
});
1048+
1049+
type Case<TMessageIds extends string, TOptions extends Readonly<unknown[]>> =
1050+
| TSESLint.ValidTestCase<TOptions>
1051+
| TSESLint.InvalidTestCase<TMessageIds, TOptions>;
1052+
function addOptional<TOptions extends Readonly<unknown[]>>(
1053+
cases: (TSESLint.ValidTestCase<TOptions> | string)[],
1054+
): TSESLint.ValidTestCase<TOptions>[];
1055+
function addOptional<
1056+
TMessageIds extends string,
1057+
TOptions extends Readonly<unknown[]>
1058+
>(
1059+
cases: TSESLint.InvalidTestCase<TMessageIds, TOptions>[],
1060+
): TSESLint.InvalidTestCase<TMessageIds, TOptions>[];
1061+
function addOptional<
1062+
TMessageIds extends string,
1063+
TOptions extends Readonly<unknown[]>
1064+
>(
1065+
cases: (Case<TMessageIds, TOptions> | string)[],
1066+
): Case<TMessageIds, TOptions>[] {
1067+
function makeOptional(code: string): string;
1068+
function makeOptional(code: string | null | undefined): string | null;
1069+
function makeOptional(code: string | null | undefined): string | null {
1070+
if (code === null || code === undefined) {
1071+
return null;
1072+
}
1073+
return (
1074+
code
1075+
.replace(/([^.])\.([^.])/, '$1?.$2')
1076+
.replace(/([^.])(\[\d)/, '$1?.$2')
1077+
// fix up s[s.length - 1] === "a" which got broken by the first regex
1078+
.replace(/(\w+?)\[(\w+?)\?\.(length - 1)/, '$1?.[$2.$3')
1079+
);
1080+
}
1081+
1082+
return cases.reduce<Case<TMessageIds, TOptions>[]>((acc, c) => {
1083+
if (typeof c === 'string') {
1084+
acc.push({
1085+
code: c,
1086+
});
1087+
acc.push({
1088+
code: makeOptional(c),
1089+
});
1090+
} else {
1091+
acc.push(c);
1092+
const code = makeOptional(c.code);
1093+
let output: string | null | undefined = null;
1094+
if ('output' in c) {
1095+
if (code.indexOf('?.')) {
1096+
output = makeOptional(c.output);
1097+
} else {
1098+
output = c.output;
1099+
}
1100+
}
1101+
acc.push({
1102+
...c,
1103+
code,
1104+
output,
1105+
});
1106+
}
1107+
1108+
return acc;
1109+
}, []);
1110+
}

Diff for: packages/eslint-plugin/typings/eslint-utils.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ declare module 'eslint-utils' {
1919
export function getPropertyName(
2020
node:
2121
| TSESTree.MemberExpression
22+
| TSESTree.OptionalMemberExpression
2223
| TSESTree.Property
2324
| TSESTree.MethodDefinition,
2425
initialScope?: TSESLint.Scope.Scope,

0 commit comments

Comments
 (0)