Skip to content

Commit fd37bc3

Browse files
authored
feat(eslint-plugin): [pref-str-starts/ends-with] optional chain… (typescript-eslint#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

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

Lines changed: 1 addition & 1 deletion
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({

packages/eslint-plugin/src/rules/prefer-includes.ts

Lines changed: 2 additions & 12 deletions
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

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

Lines changed: 77 additions & 36 deletions
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
});

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

Lines changed: 68 additions & 4 deletions
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+
}

packages/eslint-plugin/typings/eslint-utils.d.ts

Lines changed: 1 addition & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy