UNPKG

24 kBJavaScriptView Raw
1"use strict";
2Object.defineProperty(exports, "__esModule", { value: true });
3const utils_1 = require("@typescript-eslint/utils");
4const regexpp_1 = require("regexpp");
5const util_1 = require("../util");
6const EQ_OPERATORS = /^[=!]=/;
7const regexpp = new regexpp_1.RegExpParser();
8exports.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 * Check if a given node is a string.
32 * @param node The node to check.
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 * Check if a given node is a `Literal` node that is null.
40 * @param node The node to check.
41 */
42 function isNull(node) {
43 const evaluated = (0, util_1.getStaticValue)(node, globalScope);
44 return evaluated != null && evaluated.value === null;
45 }
46 /**
47 * Check if a given node is a `Literal` node that is a given value.
48 * @param node The node to check.
49 * @param value The expected value of the `Literal` node.
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 * Check if a given node is a `Literal` node that is a character.
57 * @param node The node to check.
58 * @param kind The method name to get a character.
59 */
60 function isCharacter(node) {
61 const evaluated = (0, util_1.getStaticValue)(node, globalScope);
62 return (evaluated != null &&
63 typeof evaluated.value === 'string' &&
64 // checks if the string is a character long
65 evaluated.value[0] === evaluated.value);
66 }
67 /**
68 * Check if a given node is `==`, `===`, `!=`, or `!==`.
69 * @param node The node to check.
70 */
71 function isEqualityComparison(node) {
72 return (node.type === utils_1.AST_NODE_TYPES.BinaryExpression &&
73 EQ_OPERATORS.test(node.operator));
74 }
75 /**
76 * Check if two given nodes are the same meaning.
77 * @param node1 A node to compare.
78 * @param node2 Another node to compare.
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 * Check if a given node is the expression of the length of a string.
97 *
98 * - If `length` property access of `expectedObjectNode`, it's `true`.
99 * E.g., `foo` → `foo.length` / `"foo"` → `"foo".length`
100 * - If `expectedObjectNode` is a string literal, `node` can be a number.
101 * E.g., `"foo"` → `3`
102 *
103 * @param node The node to check.
104 * @param expectedObjectNode The node which is expected as the receiver of `length` property.
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 * Check if a given node is a negative index expression
121 *
122 * E.g. `s.slice(- <expr>)`, `s.substring(s.length - <expr>)`
123 *
124 * @param node The node to check.
125 * @param expectedIndexedNode The node which is expected as the receiver of index expression.
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 * Check if a given node is the expression of the last index.
136 *
137 * E.g. `foo.length - 1`
138 *
139 * @param node The node to check.
140 * @param expectedObjectNode The node which is expected as the receiver of `length` property.
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 * Get the range of the property of a given `MemberExpression` node.
150 *
151 * - `obj[foo]` → the range of `[foo]`
152 * - `obf.foo` → the range of `.foo`
153 * - `(obj).foo` → the range of `.foo`
154 *
155 * @param node The member expression node to get.
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 * Parse a given `RegExp` pattern to that string if it's a static string.
163 * @param pattern The RegExp pattern text to parse.
164 * @param uFlag The Unicode flag of the RegExp.
165 */
166 function parseRegExpText(pattern, uFlag) {
167 // Parse it.
168 const ast = regexpp.parsePattern(pattern, undefined, undefined, uFlag);
169 if (ast.alternatives.length !== 1) {
170 return null;
171 }
172 // Drop `^`/`$` assertion.
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 // Check if it can determine a unique string.
182 if (!chars.every(c => c.type === 'Character')) {
183 return null;
184 }
185 // To string.
186 return String.fromCodePoint(...chars.map(c => c.value));
187 }
188 /**
189 * Parse a given node if it's a `RegExp` instance.
190 * @param node The node to parse.
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 * Fix code with using the right operand as the search string.
229 * For example: `foo.slice(0, 3) === 'bar'` → `foo.startsWith('bar')`
230 * @param fixer The rule fixer.
231 * @param node The node which was reported.
232 * @param kind The kind of the report.
233 * @param isNegative The flag to fix to negative condition.
234 */
235 function* fixWithRightOperand(fixer, node, kind, isNegative, isOptional) {
236 // left is CallExpression or MemberExpression.
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 * Fix code with using the first argument as the search string.
247 * For example: `foo.indexOf('bar') === 0` → `foo.startsWith('bar')`
248 * @param fixer The rule fixer.
249 * @param node The node which was reported.
250 * @param kind The kind of the report.
251 * @param negative The flag to fix to negative condition.
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 // foo[0] === "a"
268 // foo.charAt(0) === "a"
269 // foo[foo.length - 1] === "a"
270 // foo.charAt(foo.length - 1) === "a"
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 // Don't fix if it can change the behavior.
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 // foo.indexOf('bar') === 0
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 // foo.lastIndexOf('bar') === foo.length - 3
333 // foo.lastIndexOf(bar) === foo.length - bar.length
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 // foo.match(/^bar/) === null
358 // foo.match(/bar$/) === null
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 // foo.slice(0, 3) === 'bar'
391 // foo.slice(-3) === 'bar'
392 // foo.slice(-3, foo.length) === 'bar'
393 // foo.substring(0, 3) === 'bar'
394 // foo.substring(foo.length - 3) === 'bar'
395 // foo.substring(foo.length - 3, foo.length) === 'bar'
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 // Don't fix if it can change the behavior.
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 // code being checked is likely mistake:
431 // unequal length of strings being checked for equality
432 // or reliant on behavior of substring (negative indices interpreted as 0)
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 // /^bar/.test(foo)
457 // /bar$/.test(foo)
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//# sourceMappingURL=prefer-string-starts-ends-with.js.map
\No newline at end of file