1 | "use strict";
|
2 | Object.defineProperty(exports, "__esModule", { value: true });
|
3 | const utils_1 = require("@typescript-eslint/utils");
|
4 | const regexpp_1 = require("regexpp");
|
5 | const util_1 = require("../util");
|
6 | const EQ_OPERATORS = /^[=!]=/;
|
7 | const regexpp = new regexpp_1.RegExpParser();
|
8 | exports.default = (0, util_1.createRule)({
|
9 | name: 'prefer-string-starts-ends-with',
|
10 | defaultOptions: [],
|
11 | meta: {
|
12 | type: 'suggestion',
|
13 | docs: {
|
14 | description: 'Enforce the use of `String#startsWith` and `String#endsWith` instead of other equivalent methods of checking substrings',
|
15 | recommended: false,
|
16 | requiresTypeChecking: true,
|
17 | },
|
18 | messages: {
|
19 | preferStartsWith: "Use 'String#startsWith' method instead.",
|
20 | preferEndsWith: "Use the 'String#endsWith' method instead.",
|
21 | },
|
22 | schema: [],
|
23 | fixable: 'code',
|
24 | },
|
25 | create(context) {
|
26 | const globalScope = context.getScope();
|
27 | const sourceCode = context.getSourceCode();
|
28 | const service = (0, util_1.getParserServices)(context);
|
29 | const typeChecker = service.program.getTypeChecker();
|
30 | |
31 |
|
32 |
|
33 |
|
34 | function isStringType(node) {
|
35 | const objectType = typeChecker.getTypeAtLocation(service.esTreeNodeToTSNodeMap.get(node));
|
36 | return (0, util_1.getTypeName)(typeChecker, objectType) === 'string';
|
37 | }
|
38 | |
39 |
|
40 |
|
41 |
|
42 | function isNull(node) {
|
43 | const evaluated = (0, util_1.getStaticValue)(node, globalScope);
|
44 | return evaluated != null && evaluated.value === null;
|
45 | }
|
46 | |
47 |
|
48 |
|
49 |
|
50 |
|
51 | function isNumber(node, value) {
|
52 | const evaluated = (0, util_1.getStaticValue)(node, globalScope);
|
53 | return evaluated != null && evaluated.value === value;
|
54 | }
|
55 | |
56 |
|
57 |
|
58 |
|
59 |
|
60 | function isCharacter(node) {
|
61 | const evaluated = (0, util_1.getStaticValue)(node, globalScope);
|
62 | return (evaluated != null &&
|
63 | typeof evaluated.value === 'string' &&
|
64 |
|
65 | evaluated.value[0] === evaluated.value);
|
66 | }
|
67 | |
68 |
|
69 |
|
70 |
|
71 | function isEqualityComparison(node) {
|
72 | return (node.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
|
73 | EQ_OPERATORS.test(node.operator));
|
74 | }
|
75 | |
76 |
|
77 |
|
78 |
|
79 |
|
80 | function isSameTokens(node1, node2) {
|
81 | const tokens1 = sourceCode.getTokens(node1);
|
82 | const tokens2 = sourceCode.getTokens(node2);
|
83 | if (tokens1.length !== tokens2.length) {
|
84 | return false;
|
85 | }
|
86 | for (let i = 0; i < tokens1.length; ++i) {
|
87 | const token1 = tokens1[i];
|
88 | const token2 = tokens2[i];
|
89 | if (token1.type !== token2.type || token1.value !== token2.value) {
|
90 | return false;
|
91 | }
|
92 | }
|
93 | return true;
|
94 | }
|
95 | |
96 |
|
97 |
|
98 |
|
99 |
|
100 |
|
101 |
|
102 |
|
103 |
|
104 |
|
105 |
|
106 | function isLengthExpression(node, expectedObjectNode) {
|
107 | if (node.type === utils_1.AST_NODE_TYPES.MemberExpression) {
|
108 | return ((0, util_1.getPropertyName)(node, globalScope) === 'length' &&
|
109 | isSameTokens(node.object, expectedObjectNode));
|
110 | }
|
111 | const evaluatedLength = (0, util_1.getStaticValue)(node, globalScope);
|
112 | const evaluatedString = (0, util_1.getStaticValue)(expectedObjectNode, globalScope);
|
113 | return (evaluatedLength != null &&
|
114 | evaluatedString != null &&
|
115 | typeof evaluatedLength.value === 'number' &&
|
116 | typeof evaluatedString.value === 'string' &&
|
117 | evaluatedLength.value === evaluatedString.value.length);
|
118 | }
|
119 | |
120 |
|
121 |
|
122 |
|
123 |
|
124 |
|
125 |
|
126 |
|
127 | function isNegativeIndexExpression(node, expectedIndexedNode) {
|
128 | return ((node.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
129 | node.operator === '-') ||
|
130 | (node.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
|
131 | node.operator === '-' &&
|
132 | isLengthExpression(node.left, expectedIndexedNode)));
|
133 | }
|
134 | |
135 |
|
136 |
|
137 |
|
138 |
|
139 |
|
140 |
|
141 |
|
142 | function isLastIndexExpression(node, expectedObjectNode) {
|
143 | return (node.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
|
144 | node.operator === '-' &&
|
145 | isLengthExpression(node.left, expectedObjectNode) &&
|
146 | isNumber(node.right, 1));
|
147 | }
|
148 | |
149 |
|
150 |
|
151 |
|
152 |
|
153 |
|
154 |
|
155 |
|
156 |
|
157 | function getPropertyRange(node) {
|
158 | const dotOrOpenBracket = sourceCode.getTokenAfter(node.object, util_1.isNotClosingParenToken);
|
159 | return [dotOrOpenBracket.range[0], node.range[1]];
|
160 | }
|
161 | |
162 |
|
163 |
|
164 |
|
165 |
|
166 | function parseRegExpText(pattern, uFlag) {
|
167 |
|
168 | const ast = regexpp.parsePattern(pattern, undefined, undefined, uFlag);
|
169 | if (ast.alternatives.length !== 1) {
|
170 | return null;
|
171 | }
|
172 |
|
173 | const chars = ast.alternatives[0].elements;
|
174 | const first = chars[0];
|
175 | if (first.type === 'Assertion' && first.kind === 'start') {
|
176 | chars.shift();
|
177 | }
|
178 | else {
|
179 | chars.pop();
|
180 | }
|
181 |
|
182 | if (!chars.every(c => c.type === 'Character')) {
|
183 | return null;
|
184 | }
|
185 |
|
186 | return String.fromCodePoint(...chars.map(c => c.value));
|
187 | }
|
188 | |
189 |
|
190 |
|
191 |
|
192 | function parseRegExp(node) {
|
193 | const evaluated = (0, util_1.getStaticValue)(node, globalScope);
|
194 | if (evaluated == null || !(evaluated.value instanceof RegExp)) {
|
195 | return null;
|
196 | }
|
197 | const { source, flags } = evaluated.value;
|
198 | const isStartsWith = source.startsWith('^');
|
199 | const isEndsWith = source.endsWith('$');
|
200 | if (isStartsWith === isEndsWith ||
|
201 | flags.includes('i') ||
|
202 | flags.includes('m')) {
|
203 | return null;
|
204 | }
|
205 | const text = parseRegExpText(source, flags.includes('u'));
|
206 | if (text == null) {
|
207 | return null;
|
208 | }
|
209 | return { isEndsWith, isStartsWith, text };
|
210 | }
|
211 | function getLeftNode(node) {
|
212 | if (node.type === utils_1.AST_NODE_TYPES.ChainExpression) {
|
213 | return getLeftNode(node.expression);
|
214 | }
|
215 | let leftNode;
|
216 | if (node.type === utils_1.AST_NODE_TYPES.CallExpression) {
|
217 | leftNode = node.callee;
|
218 | }
|
219 | else {
|
220 | leftNode = node;
|
221 | }
|
222 | if (leftNode.type !== utils_1.AST_NODE_TYPES.MemberExpression) {
|
223 | throw new Error(`Expected a MemberExpression, got ${leftNode.type}`);
|
224 | }
|
225 | return leftNode;
|
226 | }
|
227 | |
228 |
|
229 |
|
230 |
|
231 |
|
232 |
|
233 |
|
234 |
|
235 | function* fixWithRightOperand(fixer, node, kind, isNegative, isOptional) {
|
236 |
|
237 | const leftNode = getLeftNode(node.left);
|
238 | const propertyRange = getPropertyRange(leftNode);
|
239 | if (isNegative) {
|
240 | yield fixer.insertTextBefore(node, '!');
|
241 | }
|
242 | yield fixer.replaceTextRange([propertyRange[0], node.right.range[0]], `${isOptional ? '?.' : '.'}${kind}sWith(`);
|
243 | yield fixer.replaceTextRange([node.right.range[1], node.range[1]], ')');
|
244 | }
|
245 | |
246 |
|
247 |
|
248 |
|
249 |
|
250 |
|
251 |
|
252 |
|
253 | function* fixWithArgument(fixer, node, callNode, calleeNode, kind, negative, isOptional) {
|
254 | if (negative) {
|
255 | yield fixer.insertTextBefore(node, '!');
|
256 | }
|
257 | yield fixer.replaceTextRange(getPropertyRange(calleeNode), `${isOptional ? '?.' : '.'}${kind}sWith`);
|
258 | yield fixer.removeRange([callNode.range[1], node.range[1]]);
|
259 | }
|
260 | function getParent(node) {
|
261 | var _a;
|
262 | return (0, util_1.nullThrows)(((_a = node.parent) === null || _a === void 0 ? void 0 : _a.type) === utils_1.AST_NODE_TYPES.ChainExpression
|
263 | ? node.parent.parent
|
264 | : node.parent, util_1.NullThrowsReasons.MissingParent);
|
265 | }
|
266 | return {
|
267 |
|
268 |
|
269 |
|
270 |
|
271 | [[
|
272 | 'BinaryExpression > MemberExpression.left[computed=true]',
|
273 | 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="charAt"][computed=false]',
|
274 | 'BinaryExpression > ChainExpression.left > MemberExpression[computed=true]',
|
275 | 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="charAt"][computed=false]',
|
276 | ].join(', ')](node) {
|
277 | let parentNode = getParent(node);
|
278 | let indexNode = null;
|
279 | if ((parentNode === null || parentNode === void 0 ? void 0 : parentNode.type) === utils_1.AST_NODE_TYPES.CallExpression) {
|
280 | if (parentNode.arguments.length === 1) {
|
281 | indexNode = parentNode.arguments[0];
|
282 | }
|
283 | parentNode = getParent(parentNode);
|
284 | }
|
285 | else {
|
286 | indexNode = node.property;
|
287 | }
|
288 | if (indexNode == null ||
|
289 | !isEqualityComparison(parentNode) ||
|
290 | !isStringType(node.object)) {
|
291 | return;
|
292 | }
|
293 | const isEndsWith = isLastIndexExpression(indexNode, node.object);
|
294 | const isStartsWith = !isEndsWith && isNumber(indexNode, 0);
|
295 | if (!isStartsWith && !isEndsWith) {
|
296 | return;
|
297 | }
|
298 | const eqNode = parentNode;
|
299 | context.report({
|
300 | node: parentNode,
|
301 | messageId: isStartsWith ? 'preferStartsWith' : 'preferEndsWith',
|
302 | fix(fixer) {
|
303 |
|
304 | if (!isCharacter(eqNode.right)) {
|
305 | return null;
|
306 | }
|
307 | return fixWithRightOperand(fixer, eqNode, isStartsWith ? 'start' : 'end', eqNode.operator.startsWith('!'), node.optional);
|
308 | },
|
309 | });
|
310 | },
|
311 |
|
312 | [[
|
313 | 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="indexOf"][computed=false]',
|
314 | 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="indexOf"][computed=false]',
|
315 | ].join(', ')](node) {
|
316 | const callNode = getParent(node);
|
317 | const parentNode = getParent(callNode);
|
318 | if (callNode.arguments.length !== 1 ||
|
319 | !isEqualityComparison(parentNode) ||
|
320 | !isNumber(parentNode.right, 0) ||
|
321 | !isStringType(node.object)) {
|
322 | return;
|
323 | }
|
324 | context.report({
|
325 | node: parentNode,
|
326 | messageId: 'preferStartsWith',
|
327 | fix(fixer) {
|
328 | return fixWithArgument(fixer, parentNode, callNode, node, 'start', parentNode.operator.startsWith('!'), node.optional);
|
329 | },
|
330 | });
|
331 | },
|
332 |
|
333 |
|
334 | [[
|
335 | 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="lastIndexOf"][computed=false]',
|
336 | 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="lastIndexOf"][computed=false]',
|
337 | ].join(', ')](node) {
|
338 | const callNode = getParent(node);
|
339 | const parentNode = getParent(callNode);
|
340 | if (callNode.arguments.length !== 1 ||
|
341 | !isEqualityComparison(parentNode) ||
|
342 | parentNode.right.type !== utils_1.AST_NODE_TYPES.BinaryExpression ||
|
343 | parentNode.right.operator !== '-' ||
|
344 | !isLengthExpression(parentNode.right.left, node.object) ||
|
345 | !isLengthExpression(parentNode.right.right, callNode.arguments[0]) ||
|
346 | !isStringType(node.object)) {
|
347 | return;
|
348 | }
|
349 | context.report({
|
350 | node: parentNode,
|
351 | messageId: 'preferEndsWith',
|
352 | fix(fixer) {
|
353 | return fixWithArgument(fixer, parentNode, callNode, node, 'end', parentNode.operator.startsWith('!'), node.optional);
|
354 | },
|
355 | });
|
356 | },
|
357 |
|
358 |
|
359 | [[
|
360 | 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="match"][computed=false]',
|
361 | 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="match"][computed=false]',
|
362 | ].join(', ')](node) {
|
363 | const callNode = getParent(node);
|
364 | const parentNode = getParent(callNode);
|
365 | if (!isEqualityComparison(parentNode) ||
|
366 | !isNull(parentNode.right) ||
|
367 | !isStringType(node.object)) {
|
368 | return;
|
369 | }
|
370 | const parsed = callNode.arguments.length === 1
|
371 | ? parseRegExp(callNode.arguments[0])
|
372 | : null;
|
373 | if (parsed == null) {
|
374 | return;
|
375 | }
|
376 | const { isStartsWith, text } = parsed;
|
377 | context.report({
|
378 | node: callNode,
|
379 | messageId: isStartsWith ? 'preferStartsWith' : 'preferEndsWith',
|
380 | *fix(fixer) {
|
381 | if (!parentNode.operator.startsWith('!')) {
|
382 | yield fixer.insertTextBefore(parentNode, '!');
|
383 | }
|
384 | yield fixer.replaceTextRange(getPropertyRange(node), `${node.optional ? '?.' : '.'}${isStartsWith ? 'start' : 'end'}sWith`);
|
385 | yield fixer.replaceText(callNode.arguments[0], JSON.stringify(text));
|
386 | yield fixer.removeRange([callNode.range[1], parentNode.range[1]]);
|
387 | },
|
388 | });
|
389 | },
|
390 |
|
391 |
|
392 |
|
393 |
|
394 |
|
395 |
|
396 | [[
|
397 | 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="slice"][computed=false]',
|
398 | 'BinaryExpression > CallExpression.left > MemberExpression.callee[property.name="substring"][computed=false]',
|
399 | 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="slice"][computed=false]',
|
400 | 'BinaryExpression > ChainExpression.left > CallExpression > MemberExpression.callee[property.name="substring"][computed=false]',
|
401 | ].join(', ')](node) {
|
402 | const callNode = getParent(node);
|
403 | const parentNode = getParent(callNode);
|
404 | if (!isEqualityComparison(parentNode) || !isStringType(node.object)) {
|
405 | return;
|
406 | }
|
407 | const isEndsWith = (callNode.arguments.length === 1 ||
|
408 | (callNode.arguments.length === 2 &&
|
409 | isLengthExpression(callNode.arguments[1], node.object))) &&
|
410 | isNegativeIndexExpression(callNode.arguments[0], node.object);
|
411 | const isStartsWith = !isEndsWith &&
|
412 | callNode.arguments.length === 2 &&
|
413 | isNumber(callNode.arguments[0], 0) &&
|
414 | !isNegativeIndexExpression(callNode.arguments[1], node.object);
|
415 | if (!isStartsWith && !isEndsWith) {
|
416 | return;
|
417 | }
|
418 | const eqNode = parentNode;
|
419 | const negativeIndexSupported = node.property.name === 'slice';
|
420 | context.report({
|
421 | node: parentNode,
|
422 | messageId: isStartsWith ? 'preferStartsWith' : 'preferEndsWith',
|
423 | fix(fixer) {
|
424 |
|
425 | if (eqNode.operator.length === 2 &&
|
426 | (eqNode.right.type !== utils_1.AST_NODE_TYPES.Literal ||
|
427 | typeof eqNode.right.value !== 'string')) {
|
428 | return null;
|
429 | }
|
430 |
|
431 |
|
432 |
|
433 | if (isStartsWith) {
|
434 | if (!isLengthExpression(callNode.arguments[1], eqNode.right)) {
|
435 | return null;
|
436 | }
|
437 | }
|
438 | else {
|
439 | const posNode = callNode.arguments[0];
|
440 | const posNodeIsAbsolutelyValid = (posNode.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
|
441 | posNode.operator === '-' &&
|
442 | isLengthExpression(posNode.left, node.object) &&
|
443 | isLengthExpression(posNode.right, eqNode.right)) ||
|
444 | (negativeIndexSupported &&
|
445 | posNode.type === utils_1.AST_NODE_TYPES.UnaryExpression &&
|
446 | posNode.operator === '-' &&
|
447 | isLengthExpression(posNode.argument, eqNode.right));
|
448 | if (!posNodeIsAbsolutelyValid) {
|
449 | return null;
|
450 | }
|
451 | }
|
452 | return fixWithRightOperand(fixer, parentNode, isStartsWith ? 'start' : 'end', parentNode.operator.startsWith('!'), node.optional);
|
453 | },
|
454 | });
|
455 | },
|
456 |
|
457 |
|
458 | 'CallExpression > MemberExpression.callee[property.name="test"][computed=false]'(node) {
|
459 | const callNode = getParent(node);
|
460 | const parsed = callNode.arguments.length === 1 ? parseRegExp(node.object) : null;
|
461 | if (parsed == null) {
|
462 | return;
|
463 | }
|
464 | const { isStartsWith, text } = parsed;
|
465 | const messageId = isStartsWith ? 'preferStartsWith' : 'preferEndsWith';
|
466 | const methodName = isStartsWith ? 'startsWith' : 'endsWith';
|
467 | context.report({
|
468 | node: callNode,
|
469 | messageId,
|
470 | *fix(fixer) {
|
471 | const argNode = callNode.arguments[0];
|
472 | const needsParen = argNode.type !== utils_1.AST_NODE_TYPES.Literal &&
|
473 | argNode.type !== utils_1.AST_NODE_TYPES.TemplateLiteral &&
|
474 | argNode.type !== utils_1.AST_NODE_TYPES.Identifier &&
|
475 | argNode.type !== utils_1.AST_NODE_TYPES.MemberExpression &&
|
476 | argNode.type !== utils_1.AST_NODE_TYPES.CallExpression;
|
477 | yield fixer.removeRange([callNode.range[0], argNode.range[0]]);
|
478 | if (needsParen) {
|
479 | yield fixer.insertTextBefore(argNode, '(');
|
480 | yield fixer.insertTextAfter(argNode, ')');
|
481 | }
|
482 | yield fixer.insertTextAfter(argNode, `${node.optional ? '?.' : '.'}${methodName}(${JSON.stringify(text)}`);
|
483 | },
|
484 | });
|
485 | },
|
486 | };
|
487 | },
|
488 | });
|
489 |
|
\ | No newline at end of file |